33 changed files with 1728 additions and 3915 deletions
@ -1,94 +0,0 @@ |
|||||
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.isIntermediate) |
|
||||
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,135 +0,0 @@ |
|||||
import 'dart:convert'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../modules/chat/models/message_model.dart'; |
|
||||
import '../../core/utils/logger.dart'; |
|
||||
import 'package:chat_storage/chat_storage.dart'; |
|
||||
|
|
||||
/// 聊天记录管理服务,负责保存和加载聊天记录 |
|
||||
class ChatHistoryService extends GetxService { |
|
||||
// 最大历史记录数量 |
|
||||
static const int _maxHistoryCount = 200; // 每个对话最多保存的消息数 |
|
||||
|
|
||||
@override |
|
||||
void onInit() { |
|
||||
super.onInit(); |
|
||||
Logger.info('ChatHistoryService 已初始化'); |
|
||||
} |
|
||||
|
|
||||
/// 保存聊天记录 |
|
||||
/// |
|
||||
/// [agentId] 代理ID |
|
||||
/// [messages] 消息列表 |
|
||||
Future<void> saveHistory(String agentId, List<Message> messages) async { |
|
||||
try { |
|
||||
if (messages.isEmpty) return; |
|
||||
|
|
||||
// 获取最新的消息 |
|
||||
final latestMessage = messages.last; |
|
||||
|
|
||||
// 如果是加载中的消息则跳过 |
|
||||
if (latestMessage.isIntermediate) return; |
|
||||
|
|
||||
// 直接插入最新的消息到数据库 |
|
||||
await ChatStorage.saveMessage( |
|
||||
sessionId: agentId, |
|
||||
message: latestMessage.content, |
|
||||
sender: latestMessage.role, |
|
||||
); |
|
||||
|
|
||||
// 检查并清理过多的历史记录 |
|
||||
await _cleanupOldMessages(agentId); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存聊天记录失败', e.toString()); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 清理旧消息,保持每个会话的消息数量不超过最大限制 |
|
||||
Future<void> _cleanupOldMessages(String agentId) async { |
|
||||
try { |
|
||||
final pagedResult = await ChatStorage.getMessages( |
|
||||
sessionId: agentId, |
|
||||
page: 1, |
|
||||
pageSize: 1, // 只获取总数信息 |
|
||||
); |
|
||||
|
|
||||
// 如果消息总数超过最大限制,删除最早的消息 |
|
||||
if (pagedResult.totalCount > _maxHistoryCount) { |
|
||||
final messagesToDelete = pagedResult.totalCount - _maxHistoryCount; |
|
||||
// 获取最早的N条消息ID |
|
||||
final oldestMessages = await ChatStorage.getMessages( |
|
||||
sessionId: agentId, |
|
||||
page: 1, |
|
||||
pageSize: messagesToDelete, |
|
||||
); |
|
||||
|
|
||||
// 构建要删除的消息ID列表 |
|
||||
final List<int> messageIds = oldestMessages.messages |
|
||||
.where((m) => m.id != null) |
|
||||
.map((m) => m.id!) |
|
||||
.toList(); |
|
||||
|
|
||||
// 删除这些消息 |
|
||||
if (messageIds.isNotEmpty) { |
|
||||
await ChatStorage.deleteMessages(messageIds: messageIds); |
|
||||
} |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('清理旧消息失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 加载聊天记录 |
|
||||
/// |
|
||||
/// [agentId] 代理ID |
|
||||
/// 返回消息列表,如果没有找到或解析错误则返回空列表 |
|
||||
Future<List<Message>> loadHistory(String agentId) async { |
|
||||
try { |
|
||||
final pagedResult = await ChatStorage.getMessages( |
|
||||
sessionId: agentId, |
|
||||
page: 1, |
|
||||
pageSize: _maxHistoryCount, |
|
||||
); |
|
||||
|
|
||||
if (pagedResult.messages.isEmpty) { |
|
||||
return []; |
|
||||
} |
|
||||
|
|
||||
// 将ChatStorage.ChatMessage转换为Message |
|
||||
final messages = pagedResult.messages.map((chatMessage) { |
|
||||
return Message( |
|
||||
role: chatMessage.sender, |
|
||||
content: chatMessage.message, |
|
||||
timestamp: DateTime.parse( |
|
||||
chatMessage.timestamp), // 直接使用chatMessage的timestamp |
|
||||
// isLoading 默认false |
|
||||
// isLoading: metadata['isLoading'] ?? false, |
|
||||
); |
|
||||
}).toList(); |
|
||||
|
|
||||
return messages; |
|
||||
} catch (e) { |
|
||||
Logger.error('加载聊天记录失败', e); |
|
||||
return []; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 清除特定代理的聊天记录 |
|
||||
Future<void> clearHistory(String agentId) async { |
|
||||
try { |
|
||||
await ChatStorage.deleteMessages(sessionId: agentId); |
|
||||
Logger.info('已清除聊天记录,代理: $agentId'); |
|
||||
} catch (e) { |
|
||||
Logger.error('清除聊天记录失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 清除所有聊天记录 |
|
||||
Future<void> clearAllHistory() async { |
|
||||
try { |
|
||||
await ChatStorage.clearDatabase(); |
|
||||
Logger.info('已清除所有聊天记录'); |
|
||||
} catch (e) { |
|
||||
Logger.error('清除所有聊天记录失败', e); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,343 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|
||||
import '../../../modules/chat/models/message_model.dart'; |
|
||||
import '../../models/events/voice_interaction_event.dart'; |
|
||||
import '../chat_history_service.dart'; |
|
||||
|
|
||||
/// Android语音交互服务 |
|
||||
/// |
|
||||
/// 该服务提供了与Android端的VoiceInteractionService.kt通信的接口, |
|
||||
/// 用于管理后台语音交互服务的生命周期和接收语音交互事件 |
|
||||
class AndroidVoiceInteractionService extends GetxService { |
|
||||
static AndroidVoiceInteractionService get to => Get.find(); |
|
||||
// 方法通道和事件通道 |
|
||||
static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); |
|
||||
static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); |
|
||||
|
|
||||
// 服务状态 |
|
||||
final _isServiceRunning = false.obs; |
|
||||
bool get isServiceRunning => _isServiceRunning.value; |
|
||||
|
|
||||
// 事件流控制器 |
|
||||
StreamController<VoiceInteractionEvent>? _eventStreamController; |
|
||||
Stream<VoiceInteractionEvent>? _eventStream; |
|
||||
@override |
|
||||
Stream<VoiceInteractionEvent> get eventStream => _eventStream ?? Stream.empty(); |
|
||||
|
|
||||
// 事件通道状态 |
|
||||
StreamSubscription? _eventSubscription; |
|
||||
|
|
||||
// 配置信息 |
|
||||
late String _azureSpeechKey; |
|
||||
late String _azureSpeechRegion; |
|
||||
late String _volcanoAiApiKey; |
|
||||
|
|
||||
// 初始化状态标志 |
|
||||
static bool _isInitialized = false; |
|
||||
|
|
||||
// 当前会话历史 |
|
||||
final List<Map<String, String>> _messageHistory = []; |
|
||||
|
|
||||
// 服务ID |
|
||||
static const String _serviceId = 'android_assistant'; |
|
||||
|
|
||||
/// 构造函数 |
|
||||
AndroidVoiceInteractionService() { |
|
||||
|
|
||||
} |
|
||||
|
|
||||
/// 从环境变量加载配置 |
|
||||
void _loadConfig() { |
|
||||
_azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|
||||
_azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
|
||||
_volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; |
|
||||
|
|
||||
if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { |
|
||||
Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
|
||||
} |
|
||||
|
|
||||
if (_volcanoAiApiKey.isEmpty) { |
|
||||
Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 设置事件通道 |
|
||||
void _setupEventChannel() { |
|
||||
_eventSubscription = _eventChannel |
|
||||
.receiveBroadcastStream() |
|
||||
.listen((event) { |
|
||||
if (event is Map) { |
|
||||
final String eventType = event['type'] as String? ?? ''; |
|
||||
|
|
||||
// 直接处理事件,不再等待channelReady |
|
||||
_handleVoiceInteractionEvent(event); |
|
||||
} |
|
||||
}, onError: (error) { |
|
||||
Logger.error('语音交互事件通道错误: ${error.toString()}'); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
/// 创建事件流 |
|
||||
void _createEventStream() { |
|
||||
_eventStreamController = StreamController<VoiceInteractionEvent>.broadcast(); |
|
||||
_eventStream = _eventStreamController?.stream; |
|
||||
} |
|
||||
|
|
||||
/// 初始化服务 |
|
||||
@override |
|
||||
Future<AndroidVoiceInteractionService> initialize() async { |
|
||||
try { |
|
||||
if (_isInitialized) { |
|
||||
return this; |
|
||||
} |
|
||||
_isInitialized = true; |
|
||||
|
|
||||
_loadConfig(); |
|
||||
_setupEventChannel(); |
|
||||
_createEventStream(); |
|
||||
// 检查服务是否正在运行 |
|
||||
await _checkServiceStatus(); |
|
||||
|
|
||||
await _startService(); |
|
||||
|
|
||||
Logger.info('语音交互服务初始化完成'); |
|
||||
return this; |
|
||||
} catch (e) { |
|
||||
Logger.error('语音交互服务初始化失败: ${e.toString()}'); |
|
||||
return this; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onWakeup() { |
|
||||
Logger.info('语音交互服务唤醒'); |
|
||||
} |
|
||||
|
|
||||
/// 检查服务状态 |
|
||||
Future<void> _checkServiceStatus() async { |
|
||||
try { |
|
||||
final bool isRunning = await _channel.invokeMethod('isVoiceInteractionServiceRunning') ?? false; |
|
||||
_isServiceRunning.value = isRunning; |
|
||||
Logger.info('语音交互服务状态: ${isRunning ? "运行中" : "未运行"}'); |
|
||||
} catch (e) { |
|
||||
Logger.error('检查语音交互服务状态失败: ${e.toString()}'); |
|
||||
_isServiceRunning.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 启动语音交互服务 |
|
||||
Future<bool> _startService() async { |
|
||||
if (_isServiceRunning.value) { |
|
||||
Logger.info('语音交互服务已经在运行'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('startVoiceInteractionService', { |
|
||||
'azure_speech_key': _azureSpeechKey, |
|
||||
'azure_speech_region': _azureSpeechRegion, |
|
||||
'volcano_ai_api_key': _volcanoAiApiKey, |
|
||||
}) ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
_isServiceRunning.value = true; |
|
||||
Logger.info('语音交互服务启动成功'); |
|
||||
} else { |
|
||||
Logger.error('语音交互服务启动失败'); |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('启动语音交互服务失败: ${e.toString()}'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 停止语音交互服务 |
|
||||
Future<bool> _stopService() async { |
|
||||
if (!_isServiceRunning.value) { |
|
||||
Logger.info('语音交互服务未运行'); |
|
||||
return true; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('stopVoiceInteractionService') ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
_isServiceRunning.value = false; |
|
||||
Logger.info('语音交互服务停止成功'); |
|
||||
} else { |
|
||||
Logger.error('语音交互服务停止失败'); |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止语音交互服务失败: ${e.toString()}'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 暂停语音交互(停止TTS和语音识别,但保持服务运行) |
|
||||
@override |
|
||||
Future<bool> pauseVoiceInteraction() async { |
|
||||
if (!_isServiceRunning.value) { |
|
||||
Logger.info('语音交互服务未运行,无法暂停'); |
|
||||
return false; |
|
||||
} |
|
||||
|
|
||||
try { |
|
||||
final bool result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; |
|
||||
|
|
||||
if (result) { |
|
||||
Logger.info('语音交互暂停成功'); |
|
||||
} else { |
|
||||
Logger.error('语音交互暂停失败'); |
|
||||
} |
|
||||
|
|
||||
return result; |
|
||||
} catch (e) { |
|
||||
Logger.error('暂停语音交互失败: ${e.toString()}'); |
|
||||
return false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 处理来自原生端的语音交互事件 |
|
||||
void _handleVoiceInteractionEvent(dynamic event) { |
|
||||
if (event is! Map || _eventStreamController == null) return; |
|
||||
|
|
||||
final Map<dynamic, dynamic> eventMap = event; |
|
||||
final String eventType = eventMap['type'] as String? ?? ''; |
|
||||
final int timestamp = eventMap['timestamp'] as int? ?? 0; |
|
||||
|
|
||||
// 添加时间戳日志,帮助调试 |
|
||||
Logger.info('收到原生端事件: ${eventType.toString()}, 时间戳: $timestamp, 当前时间: ${DateTime.now().millisecondsSinceEpoch}'); |
|
||||
|
|
||||
switch (eventType) { |
|
||||
case 'chatHistory': |
|
||||
final String agentId = eventMap['agentId'] as String? ?? ''; |
|
||||
final String userMessage = eventMap['userMessage'] as String? ?? ''; |
|
||||
final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; |
|
||||
|
|
||||
// 添加到消息历史 |
|
||||
if (userMessage.isNotEmpty) { |
|
||||
_messageHistory.add({'role': 'user', 'content': userMessage}); |
|
||||
_messageHistory.add({'role': 'assistant', 'content': assistantMessage}); |
|
||||
|
|
||||
// 保持历史记录在一定长度 |
|
||||
while (_messageHistory.length > 10) { |
|
||||
_messageHistory.removeAt(0); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
_eventStreamController?.add(ChatHistoryEvent( |
|
||||
agentId: agentId, |
|
||||
userMessage: userMessage, |
|
||||
assistantMessage: assistantMessage, |
|
||||
timestamp: timestamp, |
|
||||
)); |
|
||||
|
|
||||
// 保存聊天记录 |
|
||||
_saveChatHistory(agentId, userMessage, assistantMessage, timestamp); |
|
||||
|
|
||||
Logger.info('收到聊天历史事件: agentId=$agentId'); |
|
||||
break; |
|
||||
|
|
||||
case 'recognitionStarted': |
|
||||
_eventStreamController?.add(RecognitionStartedEvent( |
|
||||
timestamp: timestamp, |
|
||||
)); |
|
||||
break; |
|
||||
|
|
||||
default: |
|
||||
Logger.warning('收到未知类型的语音交互事件: ${eventType.toString()}'); |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
// 清理资源 |
|
||||
_stopService(); |
|
||||
_eventSubscription?.cancel(); |
|
||||
_eventStreamController?.close(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
/// 保存聊天记录 |
|
||||
Future<void> _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) async { |
|
||||
try { |
|
||||
if (userMessage.isEmpty) { |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
final userMsg = Message( |
|
||||
role: 'user', |
|
||||
content: userMessage, |
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), |
|
||||
); |
|
||||
|
|
||||
final assistantMsg = Message( |
|
||||
role: 'assistant', |
|
||||
content: assistantMessage, |
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 |
|
||||
); |
|
||||
|
|
||||
// 获取ChatHistoryService实例 |
|
||||
final chatHistoryService = Get.find<ChatHistoryService>(); |
|
||||
|
|
||||
// 加载现有历史记录 |
|
||||
final existingMessages = await chatHistoryService.loadHistory(agentId); |
|
||||
|
|
||||
// 添加新消息 |
|
||||
existingMessages.addAll([userMsg, assistantMsg]); |
|
||||
|
|
||||
// 保存更新后的历史记录 |
|
||||
await chatHistoryService.saveHistory(agentId, existingMessages); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存聊天记录失败: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 是否处于活跃状态 |
|
||||
@override |
|
||||
bool isActive() { |
|
||||
return _isServiceRunning.value; |
|
||||
} |
|
||||
|
|
||||
/// 获取当前对话历史 |
|
||||
@override |
|
||||
List<Map<String, String>> getMessageHistory() { |
|
||||
return List<Map<String, String>>.from(_messageHistory); |
|
||||
} |
|
||||
|
|
||||
/// 获取当前服务ID |
|
||||
@override |
|
||||
String getServiceId() { |
|
||||
return _serviceId; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
/// 聊天历史事件 |
|
||||
class ChatHistoryEvent extends VoiceInteractionEvent { |
|
||||
final String agentId; |
|
||||
final String userMessage; |
|
||||
final String assistantMessage; |
|
||||
|
|
||||
ChatHistoryEvent({ |
|
||||
required this.agentId, |
|
||||
required this.userMessage, |
|
||||
required this.assistantMessage, |
|
||||
required int timestamp, |
|
||||
}) : super(timestamp: timestamp); |
|
||||
} |
|
||||
|
|
||||
/// 语音识别开始事件 |
|
||||
class RecognitionStartedEvent extends VoiceInteractionEvent { |
|
||||
RecognitionStartedEvent({ |
|
||||
required int timestamp, |
|
||||
}) : super(timestamp: timestamp); |
|
||||
} |
|
||||
@ -1,466 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import '../asr_service.dart'; |
|
||||
import '../tts_service.dart'; |
|
||||
import '../volcano_ai_service.dart'; |
|
||||
import '../chat_history_service.dart'; |
|
||||
import '../../../modules/chat/models/message_model.dart'; |
|
||||
import '../../models/events/voice_interaction_event.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
|
|
||||
enum VoiceInteractionState { |
|
||||
idle, // 等待唤醒 |
|
||||
active, // 活跃状态 - 可以同时识别和播放 |
|
||||
} |
|
||||
|
|
||||
class IosVoiceInteractionService extends GetxService { |
|
||||
final AsrService _asrService = Get.find<AsrService>(); |
|
||||
final TtsService _ttsService = Get.find<TtsService>(); |
|
||||
final VolcanoAIService _aiService = Get.find<VolcanoAIService>(); |
|
||||
final ChatHistoryService _chatHistoryService = Get.find<ChatHistoryService>(); |
|
||||
|
|
||||
// 当前状态 |
|
||||
final Rx<VoiceInteractionState> state = VoiceInteractionState.idle.obs; |
|
||||
|
|
||||
// 是否识别到用户语音 |
|
||||
final RxBool isSpeechDetected = false.obs; |
|
||||
|
|
||||
// 是否正在处理AI响应 |
|
||||
final RxBool isProcessingAI = false.obs; |
|
||||
|
|
||||
// 是否正在播放TTS |
|
||||
final RxBool isSpeaking = false.obs; |
|
||||
|
|
||||
// 对话历史记录 |
|
||||
final List<Map<String, String>> _messageHistory = []; |
|
||||
|
|
||||
// 最大历史记录数 |
|
||||
static const int _maxHistorySize = 10; |
|
||||
|
|
||||
// 超时计时器 |
|
||||
Timer? _inactivityTimer; |
|
||||
|
|
||||
// 流订阅 |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
|
|
||||
// 当前识别的文本 |
|
||||
String _currentRecognizedText = ''; |
|
||||
// 音频输入源 |
|
||||
bool _audioSourceType = false; |
|
||||
// 事件流控制器 |
|
||||
final StreamController<VoiceInteractionEvent> _eventStreamController = |
|
||||
StreamController<VoiceInteractionEvent>.broadcast(); |
|
||||
|
|
||||
// 获取事件流 |
|
||||
@override |
|
||||
Stream<VoiceInteractionEvent> get eventStream => |
|
||||
_eventStreamController.stream; |
|
||||
|
|
||||
// 标识符,用于存储聊天历史 |
|
||||
final String _agentId = 'ios_assistant'; |
|
||||
|
|
||||
/// 初始化服务 |
|
||||
@override |
|
||||
Future<IosVoiceInteractionService> initialize() async { |
|
||||
try { |
|
||||
// 保持idle状态,不自动启动语音识别 |
|
||||
Logger.info('iOS语音交互服务初始化完成,处于idle状态'); |
|
||||
} catch (e) { |
|
||||
Logger.error('iOS语音交互服务初始化失败: ${e.toString()}'); |
|
||||
} |
|
||||
|
|
||||
return this; |
|
||||
} |
|
||||
|
|
||||
// 处理唤醒事件 |
|
||||
void onWakeup() { |
|
||||
_ttsService.speakOnce('我在呢!'); |
|
||||
isSpeaking.value = true; |
|
||||
|
|
||||
if (state.value == VoiceInteractionState.idle) { |
|
||||
_activateInteraction(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 激活交互 |
|
||||
void _activateInteraction() { |
|
||||
// 设置状态为活跃 |
|
||||
state.value = VoiceInteractionState.active; |
|
||||
|
|
||||
// 开始连续监听 |
|
||||
_startContinuousListening(); |
|
||||
|
|
||||
// 启动不活动计时器 |
|
||||
_startInactivityTimer(); |
|
||||
|
|
||||
// 发送识别开始事件 |
|
||||
_sendRecognitionStartedEvent(); |
|
||||
} |
|
||||
|
|
||||
// 开始连续监听 |
|
||||
Future<void> _startContinuousListening() async { |
|
||||
// 停止之前的监听 |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
|
|
||||
try { |
|
||||
// 开始连续识别 |
|
||||
final recognitionStream = |
|
||||
await _asrService.startContinuousRecognition(_audioSourceType); |
|
||||
|
|
||||
// 监听识别结果 |
|
||||
_recognitionSubscription = |
|
||||
recognitionStream.listen(_handleRecognitionEvent, onError: (error) { |
|
||||
Logger.error('语音识别错误: ${error.toString()}'); |
|
||||
_startContinuousListening(); // 尝试重新启动 |
|
||||
}); |
|
||||
|
|
||||
Logger.info('开始连续语音识别'); |
|
||||
} catch (e) { |
|
||||
Logger.error('启动语音识别失败: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理识别事件 |
|
||||
void _handleRecognitionEvent(RecognitionEvent event) { |
|
||||
// 更新活动时间 |
|
||||
_updateActivityTime(); |
|
||||
|
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.intermediateResult: |
|
||||
// 检测用户是否开始说话 |
|
||||
if (event.text.isNotEmpty && !isSpeechDetected.value) { |
|
||||
isSpeechDetected.value = true; |
|
||||
|
|
||||
// 用户开始讲话时,立即中断当前响应 |
|
||||
_interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); |
|
||||
} |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.finalResult: |
|
||||
if (event.text.isNotEmpty) { |
|
||||
// 最终结果,处理用户输入 |
|
||||
_processUserInput(event.text); |
|
||||
} |
|
||||
// 重置语音检测状态 |
|
||||
isSpeechDetected.value = false; |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.sessionStarted: |
|
||||
Logger.info('语音识别会话开始'); |
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.sessionStopped: |
|
||||
Logger.info('语音识别会话结束'); |
|
||||
|
|
||||
break; |
|
||||
|
|
||||
case RecognitionEventType.error: |
|
||||
case RecognitionEventType.canceled: |
|
||||
Logger.error('语音识别错误: ${event.error}'); |
|
||||
|
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 中断当前响应 |
|
||||
void _interruptCurrentResponse(String reason) { |
|
||||
Logger.info(reason); |
|
||||
|
|
||||
// 如果正在活跃状态,需要中断当前操作 |
|
||||
if (state.value == VoiceInteractionState.active) { |
|
||||
// 停止TTS播放 |
|
||||
_ttsService.stop(); |
|
||||
isSpeaking.value = false; |
|
||||
isProcessingAI.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理用户输入 |
|
||||
Future<void> _processUserInput(String text) async { |
|
||||
Logger.info('处理用户输入: $text'); |
|
||||
|
|
||||
// 设置为处理状态 |
|
||||
isProcessingAI.value = true; |
|
||||
|
|
||||
// 添加用户消息到历史记录 |
|
||||
final userMessage = {'role': 'user', 'content': text}; |
|
||||
_addToHistory(userMessage); |
|
||||
|
|
||||
// 创建用户消息对象 |
|
||||
final userMsg = Message( |
|
||||
role: 'user', |
|
||||
content: text, |
|
||||
timestamp: DateTime.now(), |
|
||||
); |
|
||||
|
|
||||
// AI响应处理标志 |
|
||||
bool isProcessingCancelled = false; |
|
||||
|
|
||||
try { |
|
||||
// 构建消息历史 |
|
||||
final messageHistory = _buildMessageHistory(); |
|
||||
|
|
||||
// 调用AI服务获取响应 |
|
||||
final responseStream = _aiService.sendMessageStream( |
|
||||
messages: messageHistory, |
|
||||
systemPrompt: "你是一个智能助手,请简明扼要地回答问题。", |
|
||||
); |
|
||||
|
|
||||
String aiResponse = ''; |
|
||||
|
|
||||
// 等待AI响应 |
|
||||
await for (final chunk in responseStream) { |
|
||||
// 检查是否被用户打断 |
|
||||
if (isSpeechDetected.value) { |
|
||||
// 用户开始说话,标记处理被取消 |
|
||||
isProcessingCancelled = true; |
|
||||
Logger.info('AI响应生成过程中被用户打断'); |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
// 如果状态已改变(可能由其他原因导致),停止处理 |
|
||||
if (state.value != VoiceInteractionState.active) { |
|
||||
isProcessingCancelled = true; |
|
||||
break; |
|
||||
} |
|
||||
|
|
||||
aiResponse += chunk; |
|
||||
|
|
||||
// 立即播放当前文本块,实现边生成边播放 |
|
||||
_speakStreamResponse(chunk); |
|
||||
} |
|
||||
|
|
||||
// 如果处理被取消,不继续后续操作 |
|
||||
if (isProcessingCancelled) { |
|
||||
isProcessingAI.value = false; |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 确保完整响应被处理 |
|
||||
if (aiResponse.isNotEmpty) { |
|
||||
// 将AI响应添加到历史记录 |
|
||||
final assistantMessage = {'role': 'assistant', 'content': aiResponse}; |
|
||||
_addToHistory(assistantMessage); |
|
||||
|
|
||||
// 创建助手消息对象 |
|
||||
final assistantMsg = Message( |
|
||||
role: 'assistant', |
|
||||
content: aiResponse, |
|
||||
timestamp: DateTime.now(), |
|
||||
); |
|
||||
|
|
||||
// 保存聊天历史 |
|
||||
await _saveChatHistory(userMsg, assistantMsg); |
|
||||
|
|
||||
// 发送聊天历史事件 |
|
||||
_sendChatHistoryEvent(userMsg.content, assistantMsg.content); |
|
||||
|
|
||||
// 刷新TTS流,确保所有文本都被播放 |
|
||||
await _ttsService.flushStream(); |
|
||||
isSpeaking.value = false; |
|
||||
} |
|
||||
|
|
||||
// 处理完成 |
|
||||
isProcessingAI.value = false; |
|
||||
} catch (e) { |
|
||||
Logger.error('AI响应处理失败: ${e.toString()}'); |
|
||||
// 错误恢复 |
|
||||
isProcessingAI.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 构建用于AI服务的消息历史 |
|
||||
List<Map<String, String>> _buildMessageHistory() { |
|
||||
return List<Map<String, String>>.from(_messageHistory); |
|
||||
} |
|
||||
|
|
||||
// 添加消息到历史记录 |
|
||||
void _addToHistory(Map<String, String> message) { |
|
||||
_messageHistory.add(message); |
|
||||
|
|
||||
// 限制历史记录大小 |
|
||||
while (_messageHistory.length > _maxHistorySize) { |
|
||||
_messageHistory.removeAt(0); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 播放流式响应 |
|
||||
void _speakStreamResponse(String chunk) { |
|
||||
if (chunk.isEmpty) return; |
|
||||
|
|
||||
// 更新播放状态 |
|
||||
isSpeaking.value = true; |
|
||||
|
|
||||
// 使用流式TTS播放 |
|
||||
_ttsService.speakStream(chunk); |
|
||||
|
|
||||
// 更新活动时间 |
|
||||
_updateActivityTime(); |
|
||||
} |
|
||||
|
|
||||
// 停止TTS |
|
||||
void _stopTts() { |
|
||||
_ttsService.stop(); |
|
||||
isSpeaking.value = false; |
|
||||
} |
|
||||
|
|
||||
// 更新最后活动时间 |
|
||||
DateTime _lastActivityTime = DateTime.now(); |
|
||||
void _updateActivityTime() { |
|
||||
_lastActivityTime = DateTime.now(); |
|
||||
} |
|
||||
|
|
||||
// 启动不活动计时器(长时间无交互会切换到空闲状态) |
|
||||
void _startInactivityTimer() { |
|
||||
_cancelInactivityTimer(); |
|
||||
|
|
||||
_inactivityTimer = Timer.periodic(Duration(seconds: 5), (timer) { |
|
||||
// 计算空闲时间 |
|
||||
final idleTime = DateTime.now().difference(_lastActivityTime).inSeconds; |
|
||||
|
|
||||
// 如果空闲超过30秒,且不在播放或检测到语音,切换到空闲 |
|
||||
if (idleTime > 8 && |
|
||||
!isSpeaking.value && |
|
||||
!isProcessingAI.value && |
|
||||
!isSpeechDetected.value) { |
|
||||
_resetToIdle(); |
|
||||
timer.cancel(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 取消不活动计时器 |
|
||||
void _cancelInactivityTimer() { |
|
||||
_inactivityTimer?.cancel(); |
|
||||
_inactivityTimer = null; |
|
||||
} |
|
||||
|
|
||||
// 重置到空闲状态 |
|
||||
void _resetToIdle() { |
|
||||
_ttsService.speakOnce('没有听到声音,暂停对话,双击耳机唤醒!'); |
|
||||
_cancelInactivityTimer(); |
|
||||
|
|
||||
// 停止TTS |
|
||||
_stopTts(); |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_asrService.stopContinuousRecognition(); |
|
||||
|
|
||||
// 重置状态 |
|
||||
state.value = VoiceInteractionState.idle; |
|
||||
isSpeechDetected.value = false; |
|
||||
isProcessingAI.value = false; |
|
||||
isSpeaking.value = false; |
|
||||
|
|
||||
// 清空历史记录 |
|
||||
_messageHistory.clear(); |
|
||||
|
|
||||
Logger.info('语音交互已重置为空闲状态'); |
|
||||
} |
|
||||
|
|
||||
// 暂停语音交互 |
|
||||
@override |
|
||||
void pauseVoiceInteraction() { |
|
||||
if (state.value != VoiceInteractionState.idle) { |
|
||||
_stopTts(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_asrService.stopContinuousRecognition(); |
|
||||
|
|
||||
// 重置所有状态 |
|
||||
state.value = VoiceInteractionState.idle; |
|
||||
isSpeechDetected.value = false; |
|
||||
isProcessingAI.value = false; |
|
||||
isSpeaking.value = false; |
|
||||
|
|
||||
// 清空历史记录 |
|
||||
_messageHistory.clear(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 恢复语音交互 |
|
||||
void resumeVoiceInteraction() { |
|
||||
if (state.value == VoiceInteractionState.idle) { |
|
||||
_activateInteraction(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 手动停止交互 |
|
||||
void stopInteraction() { |
|
||||
_resetToIdle(); |
|
||||
} |
|
||||
|
|
||||
// 获取当前状态 |
|
||||
VoiceInteractionState getCurrentState() { |
|
||||
return state.value; |
|
||||
} |
|
||||
|
|
||||
// 是否处于活跃状态 |
|
||||
@override |
|
||||
bool isActive() { |
|
||||
return state.value != VoiceInteractionState.idle; |
|
||||
} |
|
||||
|
|
||||
// 获取当前对话历史 |
|
||||
@override |
|
||||
List<Map<String, String>> getMessageHistory() { |
|
||||
return List<Map<String, String>>.from(_messageHistory); |
|
||||
} |
|
||||
|
|
||||
// 获取当前服务ID |
|
||||
@override |
|
||||
String getServiceId() { |
|
||||
return _agentId; |
|
||||
} |
|
||||
|
|
||||
// 释放资源 |
|
||||
@override |
|
||||
void onClose() { |
|
||||
_cancelInactivityTimer(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_resetToIdle(); |
|
||||
_eventStreamController.close(); |
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
// 保存聊天历史 |
|
||||
Future<void> _saveChatHistory(Message userMsg, Message assistantMsg) async { |
|
||||
try { |
|
||||
// 加载现有历史记录 |
|
||||
final existingMessages = await _chatHistoryService.loadHistory(_agentId); |
|
||||
|
|
||||
// 添加新消息 |
|
||||
existingMessages.addAll([userMsg, assistantMsg]); |
|
||||
|
|
||||
// 保存更新后的历史记录 |
|
||||
await _chatHistoryService.saveHistory(_agentId, existingMessages); |
|
||||
|
|
||||
Logger.info('保存聊天历史记录成功'); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存聊天历史记录失败: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 发送聊天历史事件 |
|
||||
void _sendChatHistoryEvent(String userMessage, String assistantMessage) { |
|
||||
final event = ChatHistoryEvent( |
|
||||
agentId: _agentId, |
|
||||
userMessage: userMessage, |
|
||||
assistantMessage: assistantMessage, |
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch, |
|
||||
); |
|
||||
|
|
||||
_eventStreamController.add(event); |
|
||||
Logger.info('已发送聊天历史事件'); |
|
||||
} |
|
||||
|
|
||||
// 发送识别开始事件 |
|
||||
void _sendRecognitionStartedEvent() { |
|
||||
final event = RecognitionStartedEvent( |
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch, |
|
||||
); |
|
||||
|
|
||||
_eventStreamController.add(event); |
|
||||
Logger.info('已发送识别开始事件'); |
|
||||
} |
|
||||
} |
|
||||
@ -1,10 +0,0 @@ |
|||||
import 'package:get/get.dart'; |
|
||||
import '../controllers/chat_controller.dart'; |
|
||||
|
|
||||
class ChatBinding extends Bindings { |
|
||||
@override |
|
||||
void dependencies() { |
|
||||
// 注册控制器 |
|
||||
Get.lazyPut<ChatController>(() => ChatController()); |
|
||||
} |
|
||||
} |
|
||||
@ -1,992 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:convert'; |
|
||||
import '../../../data/services/spotify_service.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
import 'package:flutter/widgets.dart'; |
|
||||
import 'package:get_storage/get_storage.dart'; |
|
||||
import 'package:haptic_feedback/haptic_feedback.dart'; |
|
||||
import 'package:just_audio/just_audio.dart'; |
|
||||
import 'package:chat_storage/chat_storage.dart'; |
|
||||
import '../../../data/services/ai_service.dart'; |
|
||||
import '../../../data/services/deapsound_ai_service.dart'; |
|
||||
import '../../../data/services/kouzi_ai_service.dart'; |
|
||||
import '../../../data/services/speech_impl/voice_clone_tts_service.dart'; |
|
||||
import '../models/message_model.dart'; |
|
||||
import '../../../core/utils/logger.dart'; |
|
||||
import '../../../data/services/tts_service.dart'; |
|
||||
import '../../../data/providers/agent_provider.dart'; |
|
||||
import '../../../data/models/agent.dart'; |
|
||||
import '../../../data/services/asr_service.dart'; |
|
||||
import '../../../data/services/open_ai_service_adapter.dart'; |
|
||||
|
|
||||
class ChatController extends GetxController { |
|
||||
// 服务 |
|
||||
late AiService _aiService; |
|
||||
late TtsService _ttsService; |
|
||||
final AsrService _asrService = Get.find<AsrService>(); |
|
||||
|
|
||||
late SpotifService _spotifService; |
|
||||
|
|
||||
// 添加ScrollController用于控制列表滚动 |
|
||||
final ScrollController scrollController = ScrollController(); |
|
||||
|
|
||||
StreamSubscription? _locationStream; |
|
||||
String _latLng = ''; |
|
||||
String _city = '深圳市'; |
|
||||
|
|
||||
// Agent 信息 |
|
||||
late final Agent agent; |
|
||||
|
|
||||
// 消息列表 |
|
||||
final RxList<Message> messages = <Message>[].obs; |
|
||||
|
|
||||
// 输入控制 |
|
||||
final RxString inputText = ''.obs; |
|
||||
|
|
||||
// 状态控制 |
|
||||
final RxBool isLoading = false.obs; |
|
||||
final RxBool isSending = false.obs; |
|
||||
final RxBool isTtsEnabled = true.obs; |
|
||||
final RxBool isVoiceRecognitionActive = false.obs; |
|
||||
|
|
||||
// 分页加载状态 |
|
||||
final RxInt _currentPage = 1.obs; |
|
||||
final RxInt _totalPages = 1.obs; |
|
||||
final RxBool _isLoadingMore = false.obs; |
|
||||
final RxBool _hasMoreHistory = true.obs; |
|
||||
static const int _pageSize = 20; // 每页加载的消息数量 |
|
||||
|
|
||||
// 语音交互模式 |
|
||||
bool _isVoiceInteractionMode = false; |
|
||||
|
|
||||
// 语音识别状态 |
|
||||
final RxString recognizedText = ''.obs; |
|
||||
final RxBool isListening = false.obs; |
|
||||
final RxBool isSpeechDetected = false.obs; |
|
||||
|
|
||||
// 防抖计时器 |
|
||||
Timer? _scrollDebounceTimer; |
|
||||
|
|
||||
// 流式响应控制 |
|
||||
StreamSubscription? _aiResponseSubscription; |
|
||||
StreamSubscription? _recognitionSubscription; |
|
||||
StreamSubscription? _voiceInteractionSubscription; |
|
||||
|
|
||||
// 取消标志 |
|
||||
bool _shouldCancelAiResponse = false; |
|
||||
|
|
||||
// 音频输入源 |
|
||||
bool _audioSourceType = false; |
|
||||
|
|
||||
// 自动停止语音识别计时器 |
|
||||
Timer? _autoStopTimer; |
|
||||
|
|
||||
// 记录最后一次活动时间 |
|
||||
DateTime _lastActivityTime = DateTime.now(); |
|
||||
|
|
||||
// 音乐播放 |
|
||||
final player = AudioPlayer(); |
|
||||
final RxString audiourl = ''.obs; |
|
||||
final RxBool isPlay = false.obs; |
|
||||
|
|
||||
// 按住说话、文字输入切换 |
|
||||
final RxBool isTextInput = false.obs; |
|
||||
final RxBool isHoldSpeak = false.obs; |
|
||||
String finalResult = ''; |
|
||||
|
|
||||
// 按住说话防抖 |
|
||||
bool isDebounce = false; |
|
||||
Timer? _talkDebounceTimer; |
|
||||
Timer? _talkStartDebounceTimer; |
|
||||
|
|
||||
@override |
|
||||
Future<void> onInit() async { |
|
||||
super.onInit(); |
|
||||
Logger.info('ChatController onInit'); |
|
||||
|
|
||||
// 从路由参数获取Agent信息 |
|
||||
final Map<String, dynamic> args = Get.arguments ?? {}; |
|
||||
final String agentId = |
|
||||
args['agent'] ?? args['agentId'] ?? 'personal_assistant'; |
|
||||
|
|
||||
// 获取Agent |
|
||||
final Agent? foundAgent = AgentProvider.getAgentById(agentId); |
|
||||
if (foundAgent == null) { |
|
||||
Get.back(); |
|
||||
Logger.error('无法找到Agent: $agentId'); |
|
||||
|
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
agent = foundAgent; |
|
||||
|
|
||||
// 根据Agent ID选择不同的AI服务 |
|
||||
switch (agent.id) { |
|
||||
case 'cyber_girlfriend': |
|
||||
// 亲子陪伴 |
|
||||
Get.put(KouziAIService()); |
|
||||
_aiService = Get.find<KouziAIService>(); |
|
||||
break; |
|
||||
case 'health_advisor': |
|
||||
// 多功能AI聊天 |
|
||||
Get.put(DeapsoundAIService()); |
|
||||
_aiService = Get.find<DeapsoundAIService>(); |
|
||||
break; |
|
||||
default: |
|
||||
// 默认使用OpenAIServiceAdapter |
|
||||
_aiService = Get.find<OpenAIServiceAdapter>(); |
|
||||
Logger.info('使用OpenAIServiceAdapter'); |
|
||||
} |
|
||||
Get.put(SpotifService()); |
|
||||
_spotifService = Get.find<SpotifService>(); |
|
||||
// 使用克隆音色语音合成 |
|
||||
final GetStorage storage = GetStorage(); |
|
||||
String timbre = storage.read('timbre') ?? '1'; |
|
||||
if (timbre == '1') { |
|
||||
_ttsService = Get.find<TtsService>(); |
|
||||
} else { |
|
||||
Get.put(VoiceCloneTtsService()); |
|
||||
_ttsService = Get.find<VoiceCloneTtsService>(); |
|
||||
} |
|
||||
|
|
||||
_ttsService.initialize(); |
|
||||
_asrService.initialize(); |
|
||||
|
|
||||
// 设置语音交互模式 |
|
||||
_isVoiceInteractionMode = args['isVoiceInteraction'] == true; |
|
||||
|
|
||||
// 设置TTS服务 |
|
||||
await _ttsService.setVoice(agent.voice); |
|
||||
|
|
||||
// 添加滚动监听器,用于触发加载更多 |
|
||||
scrollController.addListener(_scrollListener); |
|
||||
|
|
||||
// 加载聊天历史 (加载第一页) |
|
||||
await _loadChatHistory(); |
|
||||
|
|
||||
// 如果没有历史记录,添加欢迎消息 |
|
||||
if (messages.isEmpty && agent.welcomeMessage.isNotEmpty) { |
|
||||
messages.add(Message( |
|
||||
role: 'assistant', |
|
||||
content: agent.welcomeMessage, |
|
||||
timestamp: DateTime.now(), |
|
||||
)); |
|
||||
// 如果启用了TTS,播放欢迎消息 |
|
||||
// if (isTtsEnabled.value) { |
|
||||
// _ttsService.speak(agent.welcomeMessage); |
|
||||
// } |
|
||||
} |
|
||||
|
|
||||
// 如果isVoiceInteraction为true,自动激活语音识别 |
|
||||
if (args['isVoiceInteraction'] == true) { |
|
||||
_ttsService.speakOnce(agent.voiceWelcomeMessage ?? agent.welcomeMessage); |
|
||||
startVoiceRecognition(); |
|
||||
// 自动停止计时器会在startVoiceRecognition中启动 |
|
||||
} |
|
||||
|
|
||||
|
|
||||
player.playerStateStream.listen((state) async { |
|
||||
if (state.processingState == ProcessingState.completed) { |
|
||||
await player.seek(Duration.zero); |
|
||||
player.pause(); |
|
||||
isPlay.value = false; |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// 使用微任务在布局完成后自动滚动到底部 |
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|
||||
_scrollToBottom(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
Logger.info('ChatController onClose'); |
|
||||
|
|
||||
// 清理资源 |
|
||||
scrollController.removeListener(_scrollListener); // 移除监听器 |
|
||||
scrollController.dispose(); |
|
||||
_aiResponseSubscription?.cancel(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_voiceInteractionSubscription?.cancel(); |
|
||||
_autoStopTimer?.cancel(); |
|
||||
_scrollDebounceTimer?.cancel(); // 清理防抖计时器 |
|
||||
_talkDebounceTimer?.cancel(); |
|
||||
_talkStartDebounceTimer?.cancel(); |
|
||||
|
|
||||
_locationStream?.cancel(); |
|
||||
|
|
||||
// 停止TTS |
|
||||
_ttsService.stop(); |
|
||||
|
|
||||
if (isVoiceRecognitionActive.value) { |
|
||||
_asrService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 保存当前聊天记录 |
|
||||
if (agent.id != null) { |
|
||||
// _historyService.saveHistory(agent.id!, messages.toList()); |
|
||||
} |
|
||||
|
|
||||
player.dispose(); |
|
||||
|
|
||||
super.onClose(); |
|
||||
} |
|
||||
|
|
||||
/// 滚动到列表底部方法,添加防抖功能 |
|
||||
void _scrollToBottom() { |
|
||||
if (scrollController.hasClients && messages.isNotEmpty) { |
|
||||
try { |
|
||||
scrollController.animateTo( |
|
||||
scrollController.position.maxScrollExtent, |
|
||||
duration: Duration(milliseconds: 300), |
|
||||
curve: Curves.easeOut, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('滚动到底部失败', e); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
/// 中断当前响应 |
|
||||
void _interruptCurrentResponse(String reason) { |
|
||||
Logger.info('${reason}'); |
|
||||
|
|
||||
// 停止TTS |
|
||||
_ttsService.stop(); |
|
||||
|
|
||||
// 取消AI响应流订阅 |
|
||||
if (_aiResponseSubscription != null) { |
|
||||
_shouldCancelAiResponse = true; |
|
||||
_aiResponseSubscription!.cancel(); |
|
||||
_aiResponseSubscription = null; |
|
||||
|
|
||||
// 将AI中间消息转换为正常消息,保留已输出的内容 |
|
||||
final index = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|
||||
if (index != -1) { |
|
||||
messages[index] = messages[index].copyWith( |
|
||||
isIntermediate: false, |
|
||||
); |
|
||||
messages.refresh(); |
|
||||
} |
|
||||
|
|
||||
// 重置发送状态 |
|
||||
isSending.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 发送消息 |
|
||||
Future<void> sendMessage(String text) async { |
|
||||
if (text.trim().isEmpty) return; |
|
||||
|
|
||||
// 中断当前响应 |
|
||||
_interruptCurrentResponse("发送新消息,中断当前响应"); |
|
||||
|
|
||||
// 添加用户消息 |
|
||||
final userMessage = Message( |
|
||||
role: 'user', |
|
||||
content: text, |
|
||||
timestamp: DateTime.now(), |
|
||||
); |
|
||||
messages.add(userMessage); |
|
||||
_saveMessageToDb(userMessage); |
|
||||
|
|
||||
// 清空输入框 |
|
||||
inputText.value = ''; |
|
||||
|
|
||||
// 添加中间状态的AI消息 |
|
||||
final intermediateMessage = Message.intermediate('assistant'); |
|
||||
messages.add(intermediateMessage); |
|
||||
messages.refresh(); |
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|
||||
_scrollToBottom(); |
|
||||
}); |
|
||||
|
|
||||
isSending.value = true; |
|
||||
|
|
||||
try { |
|
||||
// 构建消息历史 |
|
||||
final List<Map<String, String>> messageHistory = _buildMessageHistory(); |
|
||||
|
|
||||
// 重置取消标志 |
|
||||
_shouldCancelAiResponse = false; |
|
||||
|
|
||||
String fullResponse = ''; |
|
||||
|
|
||||
// 获取AI响应流 |
|
||||
final responseStream = _aiService.sendMessageStream( |
|
||||
messages: messageHistory, |
|
||||
systemPrompt: agent.systemPrompt, |
|
||||
); |
|
||||
Logger.info( |
|
||||
'liwei----------- 发送用户提问 ${text}', |
|
||||
); |
|
||||
// 订阅响应流 |
|
||||
_aiResponseSubscription = responseStream.listen( |
|
||||
(chunk) { |
|
||||
if (_shouldCancelAiResponse) return; |
|
||||
Logger.info('chat ${chunk}'); |
|
||||
if (chunk is Map) { |
|
||||
Logger.info( |
|
||||
'liwei-----------收到卡片消息 ${chunk}', |
|
||||
); |
|
||||
_updateIntermediateMessage(meta: chunk); |
|
||||
if (chunk['card_spotify'] != null) { |
|
||||
play_spotify(chunk['card_spotify']); |
|
||||
} |
|
||||
if (chunk['card_music'] != null) { |
|
||||
play_music(chunk['card_music']); |
|
||||
} |
|
||||
} else if (chunk is String) { |
|
||||
fullResponse += chunk; |
|
||||
|
|
||||
// 更新中间状态的消息内容 |
|
||||
_updateIntermediateMessage(content: fullResponse); |
|
||||
|
|
||||
// 如果TTS启用,使用流式处理发送到TTS |
|
||||
if (isTtsEnabled.value) { |
|
||||
String str = chunk.replaceAll('*', ''); |
|
||||
_ttsService.speakStream(str); |
|
||||
} |
|
||||
} |
|
||||
}, |
|
||||
onDone: () { |
|
||||
Logger.info( |
|
||||
'liwei-----------结束当前问题 ${text}', |
|
||||
); |
|
||||
if (_shouldCancelAiResponse) return; |
|
||||
|
|
||||
// 完成响应,将isIntermediate设为false |
|
||||
_finalizeResponse(fullResponse); |
|
||||
|
|
||||
// 如果TTS启用,处理缓冲区中的剩余文本 |
|
||||
if (isTtsEnabled.value) { |
|
||||
_ttsService.flushStream(); |
|
||||
} |
|
||||
}, |
|
||||
onError: (error) { |
|
||||
Logger.error('AI响应错误', error); |
|
||||
|
|
||||
// 更新中间状态的消息为错误消息 |
|
||||
_handleResponseError(); |
|
||||
}, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('发送消息失败', e); |
|
||||
|
|
||||
_handleResponseError(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 构建消息历史 |
|
||||
List<Map<String, String>> _buildMessageHistory() { |
|
||||
final List<Map<String, String>> messageHistory = []; |
|
||||
|
|
||||
// 只取最近的10条消息作为上下文 |
|
||||
final recentMessages = messages.where((m) => !m.isIntermediate).toList(); |
|
||||
final startIndex = |
|
||||
recentMessages.length > 10 ? recentMessages.length - 10 : 0; |
|
||||
messageHistory.add({ |
|
||||
'role': 'user', |
|
||||
'content': '我当前所在城市是$_city,我的经纬度是$_latLng', |
|
||||
}); |
|
||||
|
|
||||
for (int i = startIndex; i < recentMessages.length; i++) { |
|
||||
final m = recentMessages[i]; |
|
||||
messageHistory.add({ |
|
||||
'role': m.role, |
|
||||
'content': m.content, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
return messageHistory; |
|
||||
} |
|
||||
|
|
||||
// 更新中间状态的消息 |
|
||||
void _updateIntermediateMessage({Map? meta, String? content}) { |
|
||||
final index = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|
||||
if (index != -1) { |
|
||||
if (meta != null) { |
|
||||
Map messagesData = messages[index].meta ?? {}; |
|
||||
messages[index] = messages[index].copyWith( |
|
||||
meta: {...messagesData, ...meta}, |
|
||||
); |
|
||||
} |
|
||||
messages[index] = messages[index].copyWith( |
|
||||
content: content, |
|
||||
); |
|
||||
messages.refresh(); |
|
||||
_scrollToBottom(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 播放音乐 |
|
||||
void play_music(Map card) async { |
|
||||
try { |
|
||||
isPlay.value = false; |
|
||||
if (audiourl.value != card['url']) { |
|
||||
audiourl.value = ''; |
|
||||
await player.setUrl(card['url']); |
|
||||
audiourl.value = card['url']; |
|
||||
player.play(); |
|
||||
} else { |
|
||||
player.play(); |
|
||||
} |
|
||||
isPlay.value = true; |
|
||||
} catch (e) { |
|
||||
Logger.error('加载异常: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 播放音乐 |
|
||||
void pause_music() async { |
|
||||
await player.pause(); |
|
||||
isPlay.value = false; |
|
||||
} |
|
||||
|
|
||||
// 播放音乐 |
|
||||
void play_spotify(Map card) async { |
|
||||
try { |
|
||||
await _spotifService.initialize(); |
|
||||
_spotifService.play(card['url'] as String); |
|
||||
isPlay.value = true; |
|
||||
audiourl.value = card['url'] as String; |
|
||||
} catch (e) { |
|
||||
Logger.error('加载异常: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 暂停播放音乐 |
|
||||
void pause_spotify() async { |
|
||||
try { |
|
||||
_spotifService.pause(); |
|
||||
isPlay.value = false; |
|
||||
} catch (e) { |
|
||||
Logger.error('加载异常: ${e.toString()}'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 更新或添加最终响应 |
|
||||
void _finalizeResponse(String content) { |
|
||||
// 查找AI的中间状态消息 |
|
||||
final index = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|
||||
if (index != -1) { |
|
||||
// 更新中间状态消息为最终状态 |
|
||||
messages[index] = messages[index].copyWith( |
|
||||
content: content, |
|
||||
isIntermediate: false, |
|
||||
timestamp: DateTime.now(), // 更新时间戳为最终时间 |
|
||||
); |
|
||||
// 保存最终的助手消息到数据库 |
|
||||
_saveMessageToDb(messages[index]); |
|
||||
} else { |
|
||||
// 如果没有中间状态消息,直接添加新消息 (理论上不应发生) |
|
||||
final assistantMsg = Message( |
|
||||
role: 'assistant', |
|
||||
content: content, |
|
||||
timestamp: DateTime.now(), |
|
||||
); |
|
||||
messages.add(assistantMsg); |
|
||||
_saveMessageToDb(assistantMsg); // 保存到数据库 |
|
||||
} |
|
||||
|
|
||||
isLoading.value = false; |
|
||||
isSending.value = false; |
|
||||
_aiResponseSubscription = null; // 清理订阅 |
|
||||
_shouldCancelAiResponse = false; // 重置取消标志 |
|
||||
|
|
||||
// 响应完成后,重新启动语音识别的自动停止计时器 |
|
||||
if (_isVoiceInteractionMode) { |
|
||||
_startAutoStopTimer(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理响应错误 |
|
||||
void _handleResponseError() { |
|
||||
// 查找AI的中间状态消息 |
|
||||
final index = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); |
|
||||
if (index != -1) { |
|
||||
messages[index] = messages[index].copyWith( |
|
||||
content: '抱歉,我遇到了一些问题,无法回应您的请求。', |
|
||||
isIntermediate: false, |
|
||||
); |
|
||||
messages.refresh(); |
|
||||
} |
|
||||
|
|
||||
isSending.value = false; |
|
||||
_aiResponseSubscription = null; |
|
||||
} |
|
||||
|
|
||||
// 切换TTS状态 |
|
||||
void toggleTts() { |
|
||||
isTtsEnabled.toggle(); |
|
||||
|
|
||||
// 如果关闭TTS,停止当前播放 |
|
||||
if (!isTtsEnabled.value) { |
|
||||
_ttsService.stop(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 按下开始说话 |
|
||||
void talkLongPress() async { |
|
||||
_talkStartDebounceTimer?.cancel(); |
|
||||
_talkDebounceTimer?.cancel(); |
|
||||
isHoldSpeak.value = true; |
|
||||
Haptics.vibrate(HapticsType.success); |
|
||||
|
|
||||
_talkStartDebounceTimer = |
|
||||
Timer(const Duration(milliseconds: 260), () async { |
|
||||
if (isDebounce) return; |
|
||||
|
|
||||
// 中断当前响应 |
|
||||
_interruptCurrentResponse("发送新消息,中断当前响应"); |
|
||||
try { |
|
||||
final recognitionStream = |
|
||||
await _asrService.startContinuousRecognition(_audioSourceType); |
|
||||
await Future.delayed(const Duration(milliseconds: 260)); |
|
||||
isDebounce = true; |
|
||||
recognizedText.value = ''; |
|
||||
finalResult = ''; |
|
||||
|
|
||||
// 监听识别事件 |
|
||||
_recognitionSubscription = recognitionStream.listen( |
|
||||
_handleRecognitionEvent, |
|
||||
onError: _handleRecognitionError, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('启动语音识别失败', e); |
|
||||
isHoldSpeak.value = false; |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 松开停止说话 |
|
||||
void talkLongPressUp() async { |
|
||||
_talkStartDebounceTimer?.cancel(); |
|
||||
_talkDebounceTimer?.cancel(); |
|
||||
isHoldSpeak.value = false; |
|
||||
|
|
||||
_talkDebounceTimer = Timer(const Duration(milliseconds: 300), () async { |
|
||||
if (!isDebounce) return; |
|
||||
try { |
|
||||
await _asrService.stopContinuousRecognition(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
String sendText = ''; |
|
||||
|
|
||||
final intermediateIndex = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|
||||
if (intermediateIndex >= 0) { |
|
||||
sendText = messages[intermediateIndex].content; |
|
||||
messages.removeAt(intermediateIndex); |
|
||||
} |
|
||||
|
|
||||
sendMessage(sendText); |
|
||||
|
|
||||
recognizedText.value = ''; |
|
||||
finalResult = ''; |
|
||||
|
|
||||
isDebounce = false; |
|
||||
} catch (e) { |
|
||||
Logger.error('停止语音识别失败', e); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 开始语音识别 |
|
||||
Future<void> startVoiceRecognition() async { |
|
||||
if (isVoiceRecognitionActive.value) return; |
|
||||
|
|
||||
try { |
|
||||
// 启动连续语音识别 |
|
||||
// 注意:在UI更新后,使用模拟波形动画替代实际麦克风输入的波形数据 |
|
||||
final recognitionStream = |
|
||||
await _asrService.startContinuousRecognition(_audioSourceType); |
|
||||
|
|
||||
isVoiceRecognitionActive.value = true; |
|
||||
isListening.value = true; |
|
||||
recognizedText.value = ''; |
|
||||
|
|
||||
// 启动自动停止计时器 |
|
||||
_startAutoStopTimer(); |
|
||||
|
|
||||
// 监听识别事件 |
|
||||
_recognitionSubscription = recognitionStream.listen( |
|
||||
_handleRecognitionEvent, |
|
||||
onError: _handleRecognitionError, |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('启动语音识别失败', e); |
|
||||
isVoiceRecognitionActive.value = false; |
|
||||
isListening.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别事件 |
|
||||
void _handleRecognitionEvent(RecognitionEvent event) { |
|
||||
// 更新活动时间 |
|
||||
_updateActivityTime(); |
|
||||
|
|
||||
switch (event.type) { |
|
||||
case RecognitionEventType.intermediateResult: |
|
||||
_handleRecognizing(event.text); |
|
||||
break; |
|
||||
case RecognitionEventType.finalResult: |
|
||||
_handleFinalResult(event.text); |
|
||||
break; |
|
||||
case RecognitionEventType.error: |
|
||||
_handleRecognitionEventError(event.error); |
|
||||
break; |
|
||||
default: |
|
||||
// 不做任何处理 |
|
||||
break; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 更新最后活动时间 |
|
||||
void _updateActivityTime() { |
|
||||
_lastActivityTime = DateTime.now(); |
|
||||
} |
|
||||
|
|
||||
// 启动自动停止计时器 |
|
||||
void _startAutoStopTimer() { |
|
||||
// 取消现有计时器 |
|
||||
_autoStopTimer?.cancel(); |
|
||||
|
|
||||
// 初始化最后活动时间 |
|
||||
_updateActivityTime(); |
|
||||
|
|
||||
// 根据模式设置超时时间 |
|
||||
final int timeoutSeconds = _isVoiceInteractionMode ? 8 : 15; |
|
||||
|
|
||||
// 创建新的计时器,每秒检查一次 |
|
||||
_autoStopTimer = Timer.periodic(Duration(seconds: 1), (timer) { |
|
||||
// 检查是否有活动 |
|
||||
bool isActive = _ttsService.isSpeaking || isSpeechDetected.value; |
|
||||
|
|
||||
// 如果有活动,更新时间并继续等待 |
|
||||
if (isActive) { |
|
||||
_updateActivityTime(); |
|
||||
return; |
|
||||
} |
|
||||
|
|
||||
// 检查空闲时间 |
|
||||
int idleSeconds = DateTime.now().difference(_lastActivityTime).inSeconds; |
|
||||
if (idleSeconds >= timeoutSeconds) { |
|
||||
timer.cancel(); |
|
||||
_stopVoiceRecognitionAutomatically(); |
|
||||
} |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别中的状态 |
|
||||
void _handleRecognizing(String text) { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
recognizedText.value = text; |
|
||||
|
|
||||
// 设置语音检测状态为true,用于控制波形动画 |
|
||||
if (!isSpeechDetected.value) { |
|
||||
isSpeechDetected.value = true; |
|
||||
} |
|
||||
|
|
||||
// 更新活动时间 |
|
||||
_updateActivityTime(); |
|
||||
|
|
||||
// 用户开始讲话时,立即停止当前TTS和中断AI响应 |
|
||||
if (isSending.value || _aiResponseSubscription != null) { |
|
||||
_interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); |
|
||||
} |
|
||||
|
|
||||
// 查找或创建用户的中间状态消息 |
|
||||
final intermediateIndex = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|
||||
|
|
||||
if (intermediateIndex >= 0) { |
|
||||
// 更新已有的中间状态消息 |
|
||||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|
||||
content: isVoiceRecognitionActive.value ? text : finalResult + text, |
|
||||
); |
|
||||
messages.refresh(); |
|
||||
} else { |
|
||||
// 添加新的中间状态消息 |
|
||||
var intermediateMessage = Message.intermediate('user'); |
|
||||
intermediateMessage = intermediateMessage.copyWith(content: text); |
|
||||
messages.add(intermediateMessage); |
|
||||
messages.refresh(); |
|
||||
} |
|
||||
|
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|
||||
_scrollToBottom(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别最终结果 |
|
||||
void _handleFinalResult(String text) { |
|
||||
if (text.isEmpty) return; |
|
||||
|
|
||||
Logger.info('处理语音识别最终结果: $text'); |
|
||||
|
|
||||
// 更新活动时间 |
|
||||
_updateActivityTime(); |
|
||||
|
|
||||
if (isVoiceRecognitionActive.value) { |
|
||||
// 一直语音说话 |
|
||||
|
|
||||
recognizedText.value = text; |
|
||||
// 查找并移除用户的中间状态消息 |
|
||||
final intermediateIndex = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|
||||
if (intermediateIndex >= 0) { |
|
||||
messages.removeAt(intermediateIndex); |
|
||||
} |
|
||||
|
|
||||
// 发送识别到的文本 |
|
||||
sendMessage(text); |
|
||||
// 重置识别文本,但不关闭语音识别框 |
|
||||
recognizedText.value = ''; |
|
||||
// 重置语音检测状态 |
|
||||
isSpeechDetected.value = false; |
|
||||
} else { |
|
||||
// 按住说话 |
|
||||
|
|
||||
finalResult += text; |
|
||||
recognizedText.value = finalResult; |
|
||||
|
|
||||
final intermediateIndex = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|
||||
|
|
||||
if (intermediateIndex >= 0) { |
|
||||
// 更新已有的中间状态消息 |
|
||||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|
||||
content: finalResult, |
|
||||
); |
|
||||
messages.refresh(); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别事件错误 |
|
||||
void _handleRecognitionEventError(String? error) { |
|
||||
Logger.error('语音识别错误: ${error ?? "未知错误"}'); |
|
||||
|
|
||||
_cleanupRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 处理语音识别流错误 |
|
||||
void _handleRecognitionError(dynamic error) { |
|
||||
Logger.error('语音识别错误', error); |
|
||||
|
|
||||
_cleanupRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 清理语音识别资源 |
|
||||
void _cleanupRecognition() { |
|
||||
// 查找并移除用户的中间状态消息 |
|
||||
final intermediateIndex = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|
||||
if (intermediateIndex >= 0) { |
|
||||
if (messages[intermediateIndex].content.trim().isEmpty) { |
|
||||
messages.removeAt(intermediateIndex); |
|
||||
} else { |
|
||||
// 如果中间状态消息有内容,将其转换为正式消息 |
|
||||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|
||||
isIntermediate: false, |
|
||||
); |
|
||||
} |
|
||||
messages.refresh(); |
|
||||
} |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
isVoiceRecognitionActive.value = false; |
|
||||
isListening.value = false; |
|
||||
isSpeechDetected.value = false; |
|
||||
_asrService.stopContinuousRecognition(); |
|
||||
} |
|
||||
|
|
||||
// 停止语音识别 |
|
||||
Future<void> stopVoiceRecognition() async { |
|
||||
if (!isVoiceRecognitionActive.value) return; |
|
||||
|
|
||||
try { |
|
||||
await _asrService.stopContinuousRecognition(); |
|
||||
_recognitionSubscription?.cancel(); |
|
||||
_recognitionSubscription = null; |
|
||||
|
|
||||
isVoiceRecognitionActive.value = false; |
|
||||
isListening.value = false; |
|
||||
recognizedText.value = ''; |
|
||||
isSpeechDetected.value = false; |
|
||||
|
|
||||
// 查找并处理用户的中间状态消息 |
|
||||
final intermediateIndex = |
|
||||
messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); |
|
||||
if (intermediateIndex >= 0) { |
|
||||
if (messages[intermediateIndex].content.trim().isEmpty) { |
|
||||
// 如果没有内容则移除 |
|
||||
messages.removeAt(intermediateIndex); |
|
||||
} else { |
|
||||
// 有内容则转为正式消息 |
|
||||
messages[intermediateIndex] = messages[intermediateIndex].copyWith( |
|
||||
isIntermediate: false, |
|
||||
); |
|
||||
} |
|
||||
messages.refresh(); |
|
||||
} |
|
||||
} catch (e) { |
|
||||
Logger.error('停止语音识别失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 清空聊天记录 |
|
||||
void clearChat() { |
|
||||
messages.clear(); |
|
||||
_ttsService.stop(); |
|
||||
// 添加欢迎消息 |
|
||||
if (agent.welcomeMessage.isNotEmpty) { |
|
||||
messages.add(Message( |
|
||||
role: 'assistant', |
|
||||
content: agent.welcomeMessage, |
|
||||
timestamp: DateTime.now(), |
|
||||
)); |
|
||||
} |
|
||||
// 删除数据库中的历史记录 |
|
||||
ChatStorage.deleteMessages(sessionId: agent.id).catchError((e) { |
|
||||
Logger.error('清空数据库聊天记录失败: ${e.toString()}'); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 滚动监听,用于触发加载更多历史记录 |
|
||||
void _scrollListener() { |
|
||||
// 当滚动到顶部并且有更多历史记录且不在加载中时,加载更多 |
|
||||
if (scrollController.position.pixels == |
|
||||
scrollController.position.minScrollExtent && |
|
||||
_hasMoreHistory.value && |
|
||||
!_isLoadingMore.value) { |
|
||||
_loadMoreHistory(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 加载聊天历史 (首次加载或刷新) |
|
||||
Future<void> _loadChatHistory() async { |
|
||||
isLoading.value = true; |
|
||||
_currentPage.value = 1; // 重置为第一页 |
|
||||
_hasMoreHistory.value = true; // 假设有更多 |
|
||||
|
|
||||
try { |
|
||||
final pagedResult = await ChatStorage.getMessages( |
|
||||
sessionId: agent.id, |
|
||||
page: _currentPage.value, |
|
||||
pageSize: _pageSize, |
|
||||
); |
|
||||
|
|
||||
_totalPages.value = pagedResult.totalPages; |
|
||||
_hasMoreHistory.value = _currentPage.value < _totalPages.value; |
|
||||
|
|
||||
final historyMessages = pagedResult.messages.map((chatMsg) { |
|
||||
return Message( |
|
||||
role: chatMsg.sender, |
|
||||
content: chatMsg.message, |
|
||||
timestamp: DateTime.parse(chatMsg.timestamp), |
|
||||
meta: chatMsg.metadata != null ? jsonDecode(chatMsg.metadata!) : null, |
|
||||
); |
|
||||
}).toList(); |
|
||||
|
|
||||
messages.assignAll(historyMessages.reversed); // 最新的在底部,所以要反转 |
|
||||
|
|
||||
// 滚动到底部 (如果是首次加载) |
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|
||||
if (scrollController.hasClients) { |
|
||||
scrollController.jumpTo(scrollController.position.maxScrollExtent); |
|
||||
} |
|
||||
}); |
|
||||
} catch (e) { |
|
||||
Logger.error('加载聊天历史失败', e); |
|
||||
} finally { |
|
||||
isLoading.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 加载更多历史记录 |
|
||||
Future<void> _loadMoreHistory() async { |
|
||||
if (_isLoadingMore.value || !_hasMoreHistory.value) return; |
|
||||
|
|
||||
_isLoadingMore.value = true; |
|
||||
_currentPage.value++; |
|
||||
|
|
||||
try { |
|
||||
final pagedResult = await ChatStorage.getMessages( |
|
||||
sessionId: agent.id, |
|
||||
page: _currentPage.value, |
|
||||
pageSize: _pageSize, |
|
||||
); |
|
||||
|
|
||||
_totalPages.value = pagedResult.totalPages; |
|
||||
_hasMoreHistory.value = _currentPage.value < _totalPages.value; |
|
||||
|
|
||||
final olderMessages = pagedResult.messages.map((chatMsg) { |
|
||||
return Message( |
|
||||
role: chatMsg.sender, |
|
||||
content: chatMsg.message, |
|
||||
timestamp: DateTime.parse(chatMsg.timestamp), |
|
||||
meta: chatMsg.metadata != null ? jsonDecode(chatMsg.metadata!) : null, |
|
||||
); |
|
||||
}).toList(); |
|
||||
|
|
||||
// 将旧消息插入到列表顶部 |
|
||||
messages.insertAll(0, olderMessages.reversed); |
|
||||
} catch (e) { |
|
||||
Logger.error('加载更多聊天历史失败', e); |
|
||||
_currentPage.value--; // 加载失败,恢复页码 |
|
||||
} finally { |
|
||||
_isLoadingMore.value = false; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 保存单条聊天消息到数据库 |
|
||||
Future<void> _saveMessageToDb(Message message) async { |
|
||||
if (message.isIntermediate) return; // 不保存中间状态的消息 |
|
||||
try { |
|
||||
await ChatStorage.saveMessage( |
|
||||
sessionId: agent.id, |
|
||||
message: message.content, |
|
||||
sender: message.role, |
|
||||
metadata: jsonEncode(message.meta), |
|
||||
); |
|
||||
} catch (e) { |
|
||||
Logger.error('保存消息到数据库失败', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// 添加消息,供外部调用 (如用户发送消息、AI回复) |
|
||||
void addMessage(Message message) { |
|
||||
messages.add(message); |
|
||||
_saveMessageToDb(message); // 保存到数据库 |
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) { |
|
||||
_scrollToBottom(); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
void _stopVoiceRecognitionAutomatically() { |
|
||||
final int timeoutSeconds = _isVoiceInteractionMode ? 8 : 15; |
|
||||
Logger.info('${timeoutSeconds}秒无活动,自动停止语音识别'); |
|
||||
stopVoiceRecognition(); |
|
||||
} |
|
||||
|
|
||||
/// 检查是否为重复消息 |
|
||||
bool _isDuplicateMessage(Message message) { |
|
||||
return messages.any((m) => |
|
||||
m.role == message.role && |
|
||||
m.content == message.content && |
|
||||
(m.timestamp.millisecondsSinceEpoch - |
|
||||
message.timestamp.millisecondsSinceEpoch) |
|
||||
.abs() < |
|
||||
5000); |
|
||||
} |
|
||||
} |
|
||||
@ -1,64 +0,0 @@ |
|||||
class Message { |
|
||||
final String role; // 'user' or 'assistant' |
|
||||
final String content; |
|
||||
final Map? meta; |
|
||||
final DateTime timestamp; |
|
||||
final bool isIntermediate; // 中间状态标记,替代isLoading |
|
||||
|
|
||||
Message({ |
|
||||
required this.role, |
|
||||
required this.content, |
|
||||
required this.timestamp, |
|
||||
this.meta, |
|
||||
this.isIntermediate = false, // 默认不是中间状态 |
|
||||
}); |
|
||||
|
|
||||
// 从JSON构造函数 |
|
||||
factory Message.fromJson(Map<String, dynamic> json) { |
|
||||
return Message( |
|
||||
role: json['role'] as String, |
|
||||
content: json['content'] as String, |
|
||||
meta: json['meta'], |
|
||||
timestamp: DateTime.parse(json['timestamp'] as String), |
|
||||
isIntermediate: json['isIntermediate'] as bool? ?? false, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 转换为JSON |
|
||||
Map<String, dynamic> toJson() { |
|
||||
return { |
|
||||
'role': role, |
|
||||
'content': content, |
|
||||
'meta': meta, |
|
||||
'timestamp': timestamp.toIso8601String(), |
|
||||
'isIntermediate': isIntermediate, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
// 创建一个中间状态的消息 |
|
||||
factory Message.intermediate(String role) { |
|
||||
return Message( |
|
||||
role: role, |
|
||||
content: '', |
|
||||
timestamp: DateTime.now(), |
|
||||
isIntermediate: true, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 复制并修改 |
|
||||
Message copyWith({ |
|
||||
String? role, |
|
||||
String? content, |
|
||||
Map? meta, |
|
||||
DateTime? timestamp, |
|
||||
bool? isIntermediate, |
|
||||
}) { |
|
||||
return Message( |
|
||||
role: role ?? this.role, |
|
||||
content: content ?? this.content, |
|
||||
meta: meta ?? this.meta, |
|
||||
timestamp: timestamp ?? this.timestamp, |
|
||||
isIntermediate: isIntermediate ?? this.isIntermediate, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,69 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
|
|
||||
class AnimatedGradientBackground extends StatefulWidget { |
|
||||
final Widget child; |
|
||||
|
|
||||
const AnimatedGradientBackground(this.child, {super.key}); |
|
||||
|
|
||||
@override |
|
||||
State<AnimatedGradientBackground> createState() => |
|
||||
_AnimatedGradientBackgroundState(); |
|
||||
} |
|
||||
|
|
||||
class _AnimatedGradientBackgroundState extends State<AnimatedGradientBackground> |
|
||||
with SingleTickerProviderStateMixin { |
|
||||
late AnimationController _controller; |
|
||||
late Animation<Alignment?> _beginAnimation; |
|
||||
late Animation<Alignment?> _endAnimation; |
|
||||
|
|
||||
final Color color1 = const Color(0xFFfbcfdd); |
|
||||
final Color color2 = const Color(0xFFe2e9fb); |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
|
|
||||
_controller = AnimationController( |
|
||||
vsync: this, |
|
||||
duration: const Duration(seconds: 10), |
|
||||
)..repeat(reverse: true); |
|
||||
|
|
||||
// 创建起始点动画 |
|
||||
_beginAnimation = AlignmentTween( |
|
||||
begin: Alignment.topCenter, |
|
||||
end: Alignment.bottomLeft, |
|
||||
).animate(_controller); |
|
||||
|
|
||||
// 创建结束点动画 |
|
||||
_endAnimation = AlignmentTween( |
|
||||
begin: Alignment.bottomCenter, |
|
||||
end: Alignment.topRight, |
|
||||
).animate(_controller); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void dispose() { |
|
||||
_controller.dispose(); |
|
||||
super.dispose(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return AnimatedBuilder( |
|
||||
animation: _controller, |
|
||||
builder: (context, child) { |
|
||||
return Container( |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
colors: [color1, color2], |
|
||||
begin: _beginAnimation.value!, |
|
||||
end: _endAnimation.value!, |
|
||||
), |
|
||||
), |
|
||||
child: child, |
|
||||
); |
|
||||
}, |
|
||||
child: widget.child, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,100 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import 'package:get/get.dart'; |
|
||||
|
|
||||
import '../controllers/chat_controller.dart'; |
|
||||
|
|
||||
class ChatMusicCard extends GetView<ChatController> { |
|
||||
final int type; |
|
||||
final Map? data; |
|
||||
final bool isDarkMode; |
|
||||
|
|
||||
const ChatMusicCard(this.type, this.data, this.isDarkMode, {super.key}); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
// final Map musiccards = data?['musiccards'].first; |
|
||||
// final Map musicaudio = data?['musicaudio']; |
|
||||
|
|
||||
return Row( |
|
||||
children: [ |
|
||||
Container( |
|
||||
width: 60.w, |
|
||||
height: 60.w, |
|
||||
clipBehavior: Clip.hardEdge, |
|
||||
decoration: BoxDecoration( |
|
||||
borderRadius: BorderRadius.circular(6.r), |
|
||||
), |
|
||||
child: Image.network( |
|
||||
data?['image'], |
|
||||
fit: BoxFit.cover, |
|
||||
), |
|
||||
), |
|
||||
10.horizontalSpace, |
|
||||
Expanded( |
|
||||
child: Text( |
|
||||
data?['name'], |
|
||||
overflow: TextOverflow.ellipsis, |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 16.sp, |
|
||||
height: 1.5, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
10.horizontalSpace, |
|
||||
Obx( |
|
||||
() => controller.audiourl.value == data?["url"] && |
|
||||
controller.isPlay.value |
|
||||
? GestureDetector( |
|
||||
onTap: () { |
|
||||
switch (type) { |
|
||||
case 0: |
|
||||
controller.pause_music(); |
|
||||
break; |
|
||||
case 1: |
|
||||
controller.pause_spotify(); |
|
||||
break; |
|
||||
default: |
|
||||
} |
|
||||
}, |
|
||||
child: Stack( |
|
||||
alignment: Alignment.center, |
|
||||
children: [ |
|
||||
Icon( |
|
||||
Icons.pause_rounded, |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
) |
|
||||
: GestureDetector( |
|
||||
onTap: () { |
|
||||
switch (type) { |
|
||||
case 0: |
|
||||
controller.play_music(data!); |
|
||||
break; |
|
||||
case 1: |
|
||||
controller.play_spotify(data!); |
|
||||
break; |
|
||||
default: |
|
||||
} |
|
||||
}, |
|
||||
child: Container( |
|
||||
width: 38.w, |
|
||||
height: 38.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: Icon( |
|
||||
Icons.play_arrow_rounded, |
|
||||
color: isDarkMode ? Colors.black : Colors.white, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
@ -1,208 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:convert'; |
|
||||
import 'dart:math'; |
|
||||
import 'dart:math' as math; |
|
||||
|
|
||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
import 'package:http/http.dart' as http; |
|
||||
|
|
||||
class ChatNavigationCard extends StatefulWidget { |
|
||||
final String content; |
|
||||
final Map? data; |
|
||||
final bool isDarkMode; |
|
||||
|
|
||||
const ChatNavigationCard(this.data, this.content, this.isDarkMode, |
|
||||
{super.key}); |
|
||||
|
|
||||
@override |
|
||||
State<ChatNavigationCard> createState() => _ChatNavigationCardState(); |
|
||||
} |
|
||||
|
|
||||
class _ChatNavigationCardState extends State<ChatNavigationCard> { |
|
||||
final String apiKey = '8bde023b7e5e00f321ea17929f10fbae'; |
|
||||
LatLng startPoint = LatLng(116.397428, 39.90923); // 起点坐标 |
|
||||
LatLng endPoint = LatLng(116.397026, 39.903303); // 终点坐标 |
|
||||
|
|
||||
late Future _mapUrl; |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
_getLocation(); |
|
||||
} |
|
||||
|
|
||||
void _getLocation() { |
|
||||
List startPointList = widget.data?['location'].split(','); |
|
||||
List endPointList = widget.data?['destination'].split(','); |
|
||||
startPoint = LatLng( |
|
||||
double.parse(startPointList[0]), |
|
||||
double.parse(startPointList[1]), |
|
||||
); |
|
||||
endPoint = LatLng( |
|
||||
double.parse(endPointList[0]), |
|
||||
double.parse(endPointList[1]), |
|
||||
); |
|
||||
_mapUrl = _getMapUrl(); |
|
||||
} |
|
||||
|
|
||||
Future<String> _getMapUrl() async { |
|
||||
// 1. 获取路径数据 |
|
||||
final route = await _fetchRouteData(); |
|
||||
|
|
||||
// 2. 解析并简化路径 |
|
||||
List<LatLng> simplified = _simplifyPath(route, epsilon: 0.0001); |
|
||||
|
|
||||
// 3. 计算地图参数 |
|
||||
final zoom = _calculateAutoZoom(simplified); |
|
||||
|
|
||||
// 4. 生成静态地图URL |
|
||||
return _buildStaticMapUrl(simplified, zoom); |
|
||||
} |
|
||||
|
|
||||
Future<List<LatLng>> _fetchRouteData() async { |
|
||||
final url = 'https://restapi.amap.com/v3/direction/driving?' |
|
||||
'origin=${startPoint.lng},${startPoint.lat}&' |
|
||||
'destination=${endPoint.lng},${endPoint.lat}&' |
|
||||
'key=$apiKey'; |
|
||||
|
|
||||
final response = await http.get(Uri.parse(url)); |
|
||||
if (response.statusCode == 200) { |
|
||||
final data = json.decode(response.body); |
|
||||
return _parsePolyline(data['route']['paths'][0]['steps']); |
|
||||
} |
|
||||
throw Exception('Failed to load route'); |
|
||||
} |
|
||||
|
|
||||
List<LatLng> _parsePolyline(List<dynamic> steps) { |
|
||||
final List<LatLng> path = []; |
|
||||
for (final step in steps) { |
|
||||
final points = (step['polyline'] as String).split(';'); |
|
||||
for (final point in points) { |
|
||||
final coords = point.split(',').map(double.parse).toList(); |
|
||||
path.add(LatLng(coords[0], coords[1])); |
|
||||
} |
|
||||
} |
|
||||
return path; |
|
||||
} |
|
||||
|
|
||||
List<LatLng> _simplifyPath(List<LatLng> points, {double epsilon = 0.0001}) { |
|
||||
return douglasPeucker(points, epsilon); |
|
||||
} |
|
||||
|
|
||||
int _calculateAutoZoom(List<LatLng> points, |
|
||||
{int mapWidth = 1024, int mapHeight = 512}) { |
|
||||
if (points.isEmpty) return 15; // 默认缩放级别 |
|
||||
|
|
||||
// 1. 计算坐标范围 |
|
||||
double minLng = double.infinity; |
|
||||
double maxLng = -double.infinity; |
|
||||
double minLat = double.infinity; |
|
||||
double maxLat = -double.infinity; |
|
||||
|
|
||||
for (final p in points) { |
|
||||
final lng = p.lng; |
|
||||
final lat = p.lat; |
|
||||
|
|
||||
minLng = math.min(minLng, lng); |
|
||||
maxLng = math.max(maxLng, lng); |
|
||||
minLat = math.min(minLat, lat); |
|
||||
maxLat = math.max(maxLat, lat); |
|
||||
} |
|
||||
|
|
||||
// 3. 计算缩放级别(基于Leaflet算法改进) |
|
||||
const worldWidth = 256; // 高德瓦片尺寸 |
|
||||
const worldHeight = 256; |
|
||||
const zoomMax = 18; // 高德最大缩放级别 |
|
||||
|
|
||||
double latRad(double lat) { |
|
||||
final sin = math.sin(lat * math.pi / 180); |
|
||||
return math.log((1 + sin) / (1 - sin)) / 2; |
|
||||
} |
|
||||
|
|
||||
final latFraction = (latRad(maxLat) - latRad(minLat)) / math.pi; |
|
||||
final lngDiff = maxLng - minLng; |
|
||||
final lngFraction = ((lngDiff < 0) ? (lngDiff + 360) : lngDiff) / 360; |
|
||||
|
|
||||
final latZoom = |
|
||||
math.log((mapHeight / worldHeight) / latFraction) / math.ln2; |
|
||||
final lngZoom = math.log((mapWidth / worldWidth) / lngFraction) / math.ln2; |
|
||||
|
|
||||
return math.min(math.min(latZoom, lngZoom).floor(), zoomMax); |
|
||||
} |
|
||||
|
|
||||
String _buildStaticMapUrl(List<LatLng> path, int zoom) { |
|
||||
final pathParam = path.map((p) => '${p.lng},${p.lat}').join(';'); |
|
||||
return 'https://restapi.amap.com/v3/staticmap?' |
|
||||
'zoom=$zoom&' |
|
||||
'size=1024*512&' |
|
||||
'paths=10,0x0000ff,1,,:$pathParam&' |
|
||||
'markers=mid,,A:${startPoint.lng},${startPoint.lat}|' |
|
||||
'mid,,B:${endPoint.lng},${endPoint.lat}&' |
|
||||
'key=$apiKey'; |
|
||||
} |
|
||||
|
|
||||
List<LatLng> douglasPeucker(List<LatLng> points, double epsilon) { |
|
||||
if (points.length < 3) return points; |
|
||||
|
|
||||
double maxDistance = 0; |
|
||||
int index = 0; |
|
||||
|
|
||||
for (int i = 1; i < points.length - 1; i++) { |
|
||||
double d = _perpendicularDistance(points[i], points.first, points.last); |
|
||||
if (d > maxDistance) { |
|
||||
index = i; |
|
||||
maxDistance = d; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
if (maxDistance > epsilon) { |
|
||||
return [ |
|
||||
...douglasPeucker(points.sublist(0, index + 1), epsilon), |
|
||||
...douglasPeucker(points.sublist(index), epsilon).skip(1) |
|
||||
]; |
|
||||
} |
|
||||
return [points.first, points.last]; |
|
||||
} |
|
||||
|
|
||||
double _perpendicularDistance(LatLng point, LatLng start, LatLng end) { |
|
||||
final area = (end.lng - start.lng) * (point.lat - start.lat) - |
|
||||
(end.lat - start.lat) * (point.lng - start.lng); |
|
||||
final length = |
|
||||
sqrt(pow(end.lng - start.lng, 2) + pow(end.lat - start.lat, 2)); |
|
||||
return area.abs() / length; |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Column( |
|
||||
children: [ |
|
||||
FutureBuilder( |
|
||||
future: _mapUrl, |
|
||||
builder: (BuildContext context, AsyncSnapshot snapshot) { |
|
||||
if (snapshot.hasData) { |
|
||||
return Image.network(snapshot.data); |
|
||||
} |
|
||||
return const CircularProgressIndicator(); |
|
||||
}, |
|
||||
), |
|
||||
10.verticalSpace, |
|
||||
Text( |
|
||||
widget.content, |
|
||||
style: TextStyle( |
|
||||
color: widget.isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 16.sp, |
|
||||
height: 1.5, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
class LatLng { |
|
||||
final double lat; |
|
||||
final double lng; |
|
||||
|
|
||||
LatLng(this.lng, this.lat); // 高德使用经度在前纬度在后的格式 |
|
||||
} |
|
||||
@ -1,820 +0,0 @@ |
|||||
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 '../../../core/theme/app_colors.dart'; |
|
||||
import 'animated_gradient_background.dart'; |
|
||||
import 'chat_music_card.dart'; |
|
||||
import 'chat_navigation_card.dart'; |
|
||||
import 'cloud_background.dart'; |
|
||||
import 'weather_day_card.dart'; |
|
||||
|
|
||||
class ChatView extends GetView<ChatController> { |
|
||||
const ChatView({Key? key}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark; |
|
||||
|
|
||||
return Obx( |
|
||||
() => Scaffold( |
|
||||
backgroundColor: |
|
||||
isDarkMode ? AppColors.darkBackground : Colors.grey[50], |
|
||||
appBar: controller.isVoiceRecognitionActive.value |
|
||||
? null |
|
||||
: _buildAppBar(isDarkMode), |
|
||||
body: controller.isVoiceRecognitionActive.value |
|
||||
? _buildVoiceInputPanel(isDarkMode) |
|
||||
: _buildBody(isDarkMode), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建AppBar |
|
||||
PreferredSizeWidget _buildAppBar(bool isDarkMode) { |
|
||||
return AppBar( |
|
||||
elevation: 1, |
|
||||
backgroundColor: isDarkMode ? AppColors.cardBackground : Colors.white, |
|
||||
leading: IconButton( |
|
||||
icon: Icon(Icons.arrow_back_ios, |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, size: 20.r), |
|
||||
onPressed: () => Get.back(), |
|
||||
), |
|
||||
titleSpacing: 0, |
|
||||
title: Row( |
|
||||
children: [ |
|
||||
// AI头像 |
|
||||
Container( |
|
||||
width: 40.r, |
|
||||
height: 40.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: AppColors.primary, |
|
||||
borderRadius: BorderRadius.circular(20.r), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.1), |
|
||||
blurRadius: 3, |
|
||||
offset: const Offset(0, 1), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: ClipRRect( |
|
||||
borderRadius: BorderRadius.circular(20.r), |
|
||||
child: controller.agent.avatarUrl.isNotEmpty |
|
||||
? Image.asset( |
|
||||
controller.agent.avatarUrl, |
|
||||
fit: BoxFit.cover, |
|
||||
errorBuilder: (context, error, stackTrace) { |
|
||||
return Icon( |
|
||||
Icons.smart_toy_rounded, |
|
||||
color: Colors.white, |
|
||||
size: 22.r, |
|
||||
); |
|
||||
}, |
|
||||
) |
|
||||
: Icon( |
|
||||
Icons.smart_toy_rounded, |
|
||||
color: Colors.white, |
|
||||
size: 22.r, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
SizedBox(width: 12.w), |
|
||||
// AI名称和状态 |
|
||||
Expanded( |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: [ |
|
||||
Text( |
|
||||
'${controller.agent.name}'.tr, |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
), |
|
||||
), |
|
||||
Row( |
|
||||
children: [ |
|
||||
Container( |
|
||||
width: 6.r, |
|
||||
height: 6.r, |
|
||||
decoration: const BoxDecoration( |
|
||||
color: Colors.green, |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(width: 4.w), |
|
||||
Text( |
|
||||
'online'.tr, // 在线 |
|
||||
style: TextStyle( |
|
||||
fontSize: 12.sp, |
|
||||
color: Colors.green, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
actions: [ |
|
||||
IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.phone, |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
), |
|
||||
onPressed: controller.startVoiceRecognition, |
|
||||
), |
|
||||
// TTS开关 |
|
||||
Obx(() => IconButton( |
|
||||
icon: Icon( |
|
||||
controller.isTtsEnabled.value |
|
||||
? Icons.volume_up |
|
||||
: Icons.volume_off, |
|
||||
color: controller.isTtsEnabled.value |
|
||||
? AppColors.primary |
|
||||
: isDarkMode |
|
||||
? Colors.grey[400] |
|
||||
: Colors.grey, |
|
||||
), |
|
||||
onPressed: controller.toggleTts, |
|
||||
)), |
|
||||
// 更多选项 |
|
||||
Container( |
|
||||
width: 34.r, |
|
||||
height: 34.r, |
|
||||
margin: EdgeInsets.only(right: 8.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: |
|
||||
isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100], |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
if (!isDarkMode) |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 2, |
|
||||
spreadRadius: 0.5, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: PopupMenuButton( |
|
||||
icon: Icon(Icons.more_vert, |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, size: 20.r), |
|
||||
shape: RoundedRectangleBorder( |
|
||||
borderRadius: BorderRadius.circular(12.r), |
|
||||
), |
|
||||
color: isDarkMode ? AppColors.cardBackground : Colors.white, |
|
||||
itemBuilder: (context) => [ |
|
||||
PopupMenuItem( |
|
||||
value: 'clear', |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
const Icon(Icons.delete_outline, color: Colors.red), |
|
||||
SizedBox(width: 8.w), |
|
||||
Text( |
|
||||
'clearChatHistory'.tr, // 清空聊天记录 |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
onTap: () { |
|
||||
// 延迟执行,避免在弹出菜单关闭前执行 |
|
||||
Future.delayed( |
|
||||
const Duration(milliseconds: 100), |
|
||||
() => controller.clearChat(), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建主体内容 |
|
||||
Widget _buildBody(bool isDarkMode) { |
|
||||
return Column( |
|
||||
children: [ |
|
||||
// 聊天内容区域 |
|
||||
Expanded( |
|
||||
child: Obx(() { |
|
||||
if (controller.messages.isEmpty) { |
|
||||
return _buildEmptyState(isDarkMode); |
|
||||
} else { |
|
||||
return _buildChatList(isDarkMode); |
|
||||
} |
|
||||
}), |
|
||||
), |
|
||||
|
|
||||
// 底部输入区域 |
|
||||
_buildTextInputPanel(isDarkMode), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建空状态 |
|
||||
Widget _buildEmptyState(bool isDarkMode) { |
|
||||
return Center( |
|
||||
child: Column( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
Container( |
|
||||
width: 80.w, |
|
||||
height: 80.w, |
|
||||
decoration: BoxDecoration( |
|
||||
color: |
|
||||
isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[100], |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: Icon( |
|
||||
Icons.chat_bubble_outline, |
|
||||
size: 40.w, |
|
||||
color: |
|
||||
isDarkMode ? Colors.white.withOpacity(0.5) : Colors.grey[400], |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 24.h), |
|
||||
Text( |
|
||||
'你好!我是${controller.agent.name}助手', |
|
||||
style: TextStyle( |
|
||||
fontSize: 18.sp, |
|
||||
fontWeight: FontWeight.bold, |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
), |
|
||||
), |
|
||||
SizedBox(height: 12.h), |
|
||||
Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 40.w), |
|
||||
child: Text( |
|
||||
'我能帮你回答问题、实时语音翻译、推荐音乐和播客等。有什么我可以帮你的吗?', |
|
||||
textAlign: TextAlign.center, |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode ? Colors.grey[400] : Colors.grey[600], |
|
||||
height: 1.5, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建聊天列表 |
|
||||
Widget _buildChatList(bool isDarkMode) { |
|
||||
return ListView.builder( |
|
||||
controller: controller.scrollController, |
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 10.h), |
|
||||
itemCount: controller.messages.length, |
|
||||
reverse: false, |
|
||||
physics: const AlwaysScrollableScrollPhysics(), |
|
||||
cacheExtent: 1000, // 增加缓存范围,减少重建 |
|
||||
itemBuilder: (context, index) { |
|
||||
final message = controller.messages[index]; |
|
||||
return _buildMessageItem(message, isDarkMode); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建消息项 |
|
||||
Widget _buildMessageItem(Message message, bool isDarkMode) { |
|
||||
final isUser = message.role == 'user'; |
|
||||
|
|
||||
return Padding( |
|
||||
padding: EdgeInsets.only(bottom: 16.h), |
|
||||
child: Row( |
|
||||
mainAxisAlignment: |
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start, |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
if (!isUser) _buildAvatar(false, isDarkMode), |
|
||||
SizedBox(width: 8.w), |
|
||||
Flexible( |
|
||||
child: Column( |
|
||||
crossAxisAlignment: |
|
||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
// 消息气泡 |
|
||||
Container( |
|
||||
padding: EdgeInsets.all(12.r), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isUser |
|
||||
? AppColors.primary |
|
||||
: isDarkMode |
|
||||
? AppColors.cardBackground |
|
||||
: Colors.grey[200], |
|
||||
borderRadius: BorderRadius.circular(16.r), |
|
||||
), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
// 消息内容 |
|
||||
_buildMessageContent(message, isDarkMode, isUser), |
|
||||
|
|
||||
// 加载中动画 |
|
||||
if (message.isIntermediate) |
|
||||
Padding( |
|
||||
padding: EdgeInsets.only(top: 8.h), |
|
||||
child: Row( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
children: List.generate( |
|
||||
3, |
|
||||
(index) => _buildLoadingDot(index, isDarkMode), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 时间戳 |
|
||||
Padding( |
|
||||
padding: EdgeInsets.only(top: 4.h, left: 4.w, right: 4.w), |
|
||||
child: Text( |
|
||||
_formatTime(message.timestamp), |
|
||||
style: TextStyle( |
|
||||
fontSize: 10.sp, |
|
||||
color: isDarkMode ? Colors.grey[500] : Colors.grey[500], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
SizedBox(width: 8.w), |
|
||||
if (isUser) _buildAvatar(true, isDarkMode), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Widget _buildMessageContent(Message message, bool isDarkMode, bool isUser) { |
|
||||
Widget box = const SizedBox(); |
|
||||
if (message.content.isNotEmpty) { |
|
||||
box = SelectableText( |
|
||||
message.content.tr, |
|
||||
style: TextStyle( |
|
||||
color: isUser |
|
||||
? Colors.white |
|
||||
: isDarkMode |
|
||||
? Colors.white |
|
||||
: Colors.black, |
|
||||
fontSize: 16.sp, |
|
||||
height: 1.5, |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
if (message.meta != null) { |
|
||||
if (message.meta?['card_weather'] != null) { |
|
||||
box = WeatherDayCard(message.meta?['card_weather'], isDarkMode); |
|
||||
} |
|
||||
if (message.meta?['card_music'] != null) { |
|
||||
box = ChatMusicCard(0, message.meta?['card_music'], isDarkMode); |
|
||||
} |
|
||||
if (message.meta?['card_spotify'] != null) { |
|
||||
box = ChatMusicCard(1, message.meta?['card_spotify'], isDarkMode); |
|
||||
} |
|
||||
if (message.meta?['card_navigation'] != null) { |
|
||||
box = ChatNavigationCard( |
|
||||
message.meta?['card_navigation'], message.content, isDarkMode); |
|
||||
} |
|
||||
} |
|
||||
return box; |
|
||||
} |
|
||||
|
|
||||
// 构建文本输入面板 |
|
||||
Widget _buildTextInputPanel(bool isDarkMode) { |
|
||||
final textController = |
|
||||
TextEditingController(text: controller.inputText.value); |
|
||||
|
|
||||
return Container( |
|
||||
height: 48.w, |
|
||||
margin: EdgeInsets.only(left: 16.w, right: 16.w, top: 10.w, bottom: 20.w), |
|
||||
clipBehavior: Clip.hardEdge, |
|
||||
decoration: BoxDecoration( |
|
||||
color: controller.isHoldSpeak.value |
|
||||
? AppColors.primary |
|
||||
: isDarkMode |
|
||||
? AppColors.cardBackground |
|
||||
: Colors.white, |
|
||||
borderRadius: BorderRadius.circular(12.w), |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 3, |
|
||||
offset: const Offset(0, -1), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Row( |
|
||||
children: [ |
|
||||
if (!controller.isHoldSpeak.value) 6.horizontalSpace, |
|
||||
if (!controller.isHoldSpeak.value) |
|
||||
Padding( |
|
||||
padding: EdgeInsets.all(6.w), |
|
||||
child: Icon( |
|
||||
Icons.camera_alt_outlined, |
|
||||
size: 28.w, |
|
||||
), |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Obx( |
|
||||
() => controller.isTextInput.value |
|
||||
? Center( |
|
||||
child: TextField( |
|
||||
controller: textController, |
|
||||
style: TextStyle( |
|
||||
fontSize: 15.sp, |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
), |
|
||||
textInputAction: TextInputAction.send, |
|
||||
decoration: InputDecoration( |
|
||||
hintText: 'inputMessage'.tr, // 输入消息... |
|
||||
hintStyle: TextStyle(color: Colors.grey[500]), |
|
||||
filled: false, |
|
||||
border: InputBorder.none, |
|
||||
focusedBorder: InputBorder.none, |
|
||||
contentPadding: EdgeInsets.only(left: 6.w), |
|
||||
), |
|
||||
onSubmitted: (value) { |
|
||||
if (value.trim().isNotEmpty) { |
|
||||
controller.sendMessage(value); |
|
||||
textController.clear(); |
|
||||
controller.inputText.value = ''; // 清空输入框 |
|
||||
} |
|
||||
}, |
|
||||
), |
|
||||
) |
|
||||
: Listener( |
|
||||
onPointerDown: (event) { |
|
||||
controller.talkLongPress(); |
|
||||
}, |
|
||||
onPointerUp: (event) { |
|
||||
controller.talkLongPressUp(); |
|
||||
}, |
|
||||
child: Container( |
|
||||
padding: EdgeInsets.only( |
|
||||
left: controller.isHoldSpeak.value ? 0 : 40.w), |
|
||||
color: Colors.transparent, |
|
||||
child: controller.isHoldSpeak.value |
|
||||
? Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: List.generate(40, (int index) { |
|
||||
return Container( |
|
||||
width: 3.w, |
|
||||
height: 10.w, |
|
||||
margin: |
|
||||
EdgeInsets.symmetric(horizontal: 2.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: Colors.white, |
|
||||
borderRadius: BorderRadius.circular(3.r), |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
) |
|
||||
: Center( |
|
||||
child: Text( |
|
||||
'按住说话', |
|
||||
style: TextStyle( |
|
||||
fontSize: 16.sp, |
|
||||
color: isDarkMode |
|
||||
? Colors.white |
|
||||
: Colors.black87, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
if (!controller.isHoldSpeak.value) |
|
||||
GestureDetector( |
|
||||
onTap: () { |
|
||||
controller.isTextInput.value = !controller.isTextInput.value; |
|
||||
}, |
|
||||
child: Container( |
|
||||
width: 28.w, |
|
||||
height: 28.w, |
|
||||
margin: EdgeInsets.all(6.w), |
|
||||
alignment: Alignment.center, |
|
||||
decoration: BoxDecoration( |
|
||||
borderRadius: BorderRadius.circular(28.r), |
|
||||
border: Border.all( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
width: 2, |
|
||||
), |
|
||||
), |
|
||||
child: Obx( |
|
||||
() => Icon( |
|
||||
controller.isTextInput.value ? Icons.mic : Icons.keyboard, |
|
||||
size: 20.w, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
if (!controller.isHoldSpeak.value) |
|
||||
Container( |
|
||||
width: 28.w, |
|
||||
height: 28.w, |
|
||||
margin: EdgeInsets.all(6.w), |
|
||||
alignment: Alignment.center, |
|
||||
decoration: BoxDecoration( |
|
||||
borderRadius: BorderRadius.circular(28.r), |
|
||||
border: Border.all( |
|
||||
color: isDarkMode ? Colors.white : Colors.black87, |
|
||||
width: 2, |
|
||||
), |
|
||||
), |
|
||||
child: Icon( |
|
||||
Icons.add, |
|
||||
size: 20.w, |
|
||||
), |
|
||||
), |
|
||||
if (!controller.isHoldSpeak.value) 6.horizontalSpace, |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建语音输入面板 |
|
||||
Widget _buildVoiceInputPanel(bool isDarkMode) { |
|
||||
return AnimatedGradientBackground( |
|
||||
SafeArea( |
|
||||
child: Column( |
|
||||
children: [ |
|
||||
Row( |
|
||||
children: [], |
|
||||
), |
|
||||
Expanded( |
|
||||
child: Center( |
|
||||
child: Container( |
|
||||
width: 200.w, |
|
||||
height: 200.w, |
|
||||
clipBehavior: Clip.hardEdge, |
|
||||
decoration: BoxDecoration( |
|
||||
gradient: LinearGradient( |
|
||||
colors: [ |
|
||||
Colors.blue.withOpacity(0.4), |
|
||||
Colors.purple.withOpacity(0.4) |
|
||||
], |
|
||||
begin: Alignment.centerLeft, |
|
||||
end: Alignment.centerRight, |
|
||||
), |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
child: CircularFlowAnimation(), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
SizedBox( |
|
||||
height: 80.h, |
|
||||
child: Padding( |
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w), |
|
||||
child: Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
crossAxisAlignment: CrossAxisAlignment.center, |
|
||||
children: [ |
|
||||
// 语音录制按钮 |
|
||||
Container( |
|
||||
width: 60.r, |
|
||||
height: 60.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode |
|
||||
? Colors.white.withOpacity(0.1) |
|
||||
: Colors.white, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.1), |
|
||||
blurRadius: 8, |
|
||||
spreadRadius: 1, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Center( |
|
||||
child: Obx(() => Icon( |
|
||||
controller.isListening.value |
|
||||
? Icons.mic |
|
||||
: Icons.mic_none, |
|
||||
color: controller.isListening.value |
|
||||
? AppColors.primary |
|
||||
: Colors.grey.shade400, |
|
||||
size: 28.r, |
|
||||
)), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 中间音频波形和状态文本 |
|
||||
Expanded( |
|
||||
child: Column( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: [ |
|
||||
// 音频波形区域 |
|
||||
Container( |
|
||||
height: 50.h, |
|
||||
child: Obx(() { |
|
||||
final isSpeaking = |
|
||||
controller.isSpeechDetected.value; |
|
||||
return Center( |
|
||||
child: Container( |
|
||||
width: 220.w, |
|
||||
height: 30.h, |
|
||||
child: Row( |
|
||||
mainAxisSize: MainAxisSize.min, |
|
||||
mainAxisAlignment: MainAxisAlignment.center, |
|
||||
children: List.generate( |
|
||||
23, |
|
||||
(index) { |
|
||||
// 随机高度模拟波形 |
|
||||
double height = isSpeaking |
|
||||
? (Random().nextDouble() * 20.0 + |
|
||||
5.0) |
|
||||
.h |
|
||||
: 2.h; |
|
||||
|
|
||||
return SizedBox( |
|
||||
width: 6.w, |
|
||||
child: Center( |
|
||||
child: AnimatedContainer( |
|
||||
duration: const Duration( |
|
||||
milliseconds: 500), |
|
||||
width: 2.5.w, |
|
||||
height: height, |
|
||||
margin: EdgeInsets.symmetric( |
|
||||
horizontal: 1.5.w), |
|
||||
decoration: BoxDecoration( |
|
||||
color: isSpeaking |
|
||||
? AppColors.primary |
|
||||
: isDarkMode |
|
||||
? Colors.grey.shade600 |
|
||||
: Colors.grey.shade400, |
|
||||
borderRadius: |
|
||||
BorderRadius.circular(2.r), |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
}), |
|
||||
), |
|
||||
|
|
||||
// 语音状态文本 |
|
||||
Obx(() => Text( |
|
||||
controller.isSpeechDetected.value |
|
||||
? 'imListening'.tr // 正在聆听... |
|
||||
: 'canStartTalking'.tr, // 你可以开始说话 |
|
||||
style: TextStyle( |
|
||||
fontSize: 14.sp, |
|
||||
color: isDarkMode |
|
||||
? Colors.grey[400] |
|
||||
: Colors.grey[700], |
|
||||
fontWeight: FontWeight.w500, |
|
||||
), |
|
||||
)), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
// 关闭按钮 |
|
||||
Container( |
|
||||
width: 60.r, |
|
||||
height: 60.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isDarkMode |
|
||||
? Colors.white.withOpacity(0.1) |
|
||||
: Colors.white, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.1), |
|
||||
blurRadius: 8, |
|
||||
spreadRadius: 1, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: Center( |
|
||||
child: IconButton( |
|
||||
icon: Icon( |
|
||||
Icons.close, |
|
||||
color: isDarkMode |
|
||||
? Colors.white |
|
||||
: Colors.grey.shade600, |
|
||||
size: 28.r, |
|
||||
), |
|
||||
padding: EdgeInsets.zero, |
|
||||
onPressed: controller.stopVoiceRecognition, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建头像 |
|
||||
Widget _buildAvatar(bool isUser, bool isDarkMode) { |
|
||||
return Container( |
|
||||
width: 36.r, |
|
||||
height: 36.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: isUser |
|
||||
? (isDarkMode ? Colors.white.withOpacity(0.1) : Colors.grey[200]) |
|
||||
: AppColors.primary, |
|
||||
shape: BoxShape.circle, |
|
||||
boxShadow: [ |
|
||||
BoxShadow( |
|
||||
color: Colors.black.withOpacity(0.05), |
|
||||
blurRadius: 2, |
|
||||
offset: const Offset(0, 1), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
child: isUser |
|
||||
? Center( |
|
||||
child: Icon( |
|
||||
Icons.person, |
|
||||
color: isDarkMode ? Colors.white : Colors.grey[700], |
|
||||
size: 20.r, |
|
||||
), |
|
||||
) |
|
||||
: ClipRRect( |
|
||||
borderRadius: BorderRadius.circular(18.r), |
|
||||
child: controller.agent.avatarUrl.isNotEmpty |
|
||||
? Image.asset( |
|
||||
controller.agent.avatarUrl, |
|
||||
fit: BoxFit.cover, |
|
||||
errorBuilder: (context, error, stackTrace) { |
|
||||
return Icon( |
|
||||
Icons.smart_toy_rounded, |
|
||||
color: Colors.white, |
|
||||
size: 20.r, |
|
||||
); |
|
||||
}, |
|
||||
) |
|
||||
: Icon( |
|
||||
Icons.smart_toy_rounded, |
|
||||
color: Colors.white, |
|
||||
size: 20.r, |
|
||||
), |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 构建加载动画点 |
|
||||
Widget _buildLoadingDot(int index, bool isDarkMode) { |
|
||||
return AnimatedBuilder( |
|
||||
animation: const AlwaysStoppedAnimation(0), |
|
||||
builder: (context, child) { |
|
||||
return Container( |
|
||||
margin: EdgeInsets.symmetric(horizontal: 2.w), |
|
||||
width: 8.r, |
|
||||
height: 8.r, |
|
||||
decoration: BoxDecoration( |
|
||||
color: |
|
||||
isDarkMode ? Colors.white.withOpacity(0.5) : Colors.grey[400], |
|
||||
shape: BoxShape.circle, |
|
||||
), |
|
||||
); |
|
||||
}, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// 格式化时间 |
|
||||
String _formatTime(DateTime time) { |
|
||||
final now = DateTime.now(); |
|
||||
final today = DateTime(now.year, now.month, now.day); |
|
||||
//final yesterday = today.subtract(const Duration(days: 1)); |
|
||||
final messageDate = DateTime(time.year, time.month, time.day); |
|
||||
|
|
||||
String timeStr = |
|
||||
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; |
|
||||
|
|
||||
if (messageDate == today) { |
|
||||
return timeStr; // 只显示时间,如 "14:30" |
|
||||
} |
|
||||
// else if (messageDate == yesterday) { |
|
||||
// return 'Yesterday $timeStr'; // 如 "Yesterday 14:30" |
|
||||
// } |
|
||||
else { |
|
||||
// 国际通用日期格式 "MM-dd HH:mm" |
|
||||
return '${time.month.toString().padLeft(2, '0')}-${time.day.toString().padLeft(2, '0')} $timeStr'; |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
@ -1,144 +0,0 @@ |
|||||
import 'dart:math'; |
|
||||
|
|
||||
import 'package:flutter/material.dart'; |
|
||||
|
|
||||
class CircularFlowAnimation extends StatefulWidget { |
|
||||
const CircularFlowAnimation({super.key}); |
|
||||
|
|
||||
@override |
|
||||
State<CircularFlowAnimation> createState() => _CircularFlowAnimationState(); |
|
||||
} |
|
||||
|
|
||||
class _CircularFlowAnimationState extends State<CircularFlowAnimation> |
|
||||
with SingleTickerProviderStateMixin { |
|
||||
late AnimationController _controller; |
|
||||
|
|
||||
@override |
|
||||
void initState() { |
|
||||
super.initState(); |
|
||||
_controller = AnimationController( |
|
||||
vsync: this, |
|
||||
duration: const Duration(seconds: 20), |
|
||||
)..repeat(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
void dispose() { |
|
||||
_controller.dispose(); |
|
||||
super.dispose(); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return CustomPaint( |
|
||||
painter: CircularFlowPainter(_controller), |
|
||||
size: Size.infinite, |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
class CircularFlowPainter extends CustomPainter { |
|
||||
final Animation<double> animation; |
|
||||
|
|
||||
CircularFlowPainter(this.animation) : super(repaint: animation); |
|
||||
|
|
||||
@override |
|
||||
void paint(Canvas canvas, Size size) { |
|
||||
final center = Offset(size.width / 2, size.height / 2); |
|
||||
const baseRadius = 150.0; |
|
||||
final time = animation.value * 2 * pi; |
|
||||
|
|
||||
// 绘制圆形路径 |
|
||||
_drawCircularPath(canvas, center, baseRadius); |
|
||||
|
|
||||
// 绘制三个围绕圆形运动的渐变云朵 |
|
||||
_drawOrbitingCloud( |
|
||||
canvas: canvas, |
|
||||
center: center, |
|
||||
time: time, |
|
||||
colors: [Colors.blue, Colors.purple], |
|
||||
orbitRadius: baseRadius, |
|
||||
speed: 0.5, |
|
||||
direction: 1, |
|
||||
); |
|
||||
|
|
||||
_drawOrbitingCloud( |
|
||||
canvas: canvas, |
|
||||
center: center, |
|
||||
time: time, |
|
||||
colors: [Colors.pink, Colors.orange], |
|
||||
orbitRadius: baseRadius * 0.8, |
|
||||
speed: 1.0, |
|
||||
direction: -1, |
|
||||
); |
|
||||
|
|
||||
_drawOrbitingCloud( |
|
||||
canvas: canvas, |
|
||||
center: center, |
|
||||
time: time, |
|
||||
colors: [Colors.green, Colors.yellow], |
|
||||
orbitRadius: baseRadius * 1.2, |
|
||||
speed: 0.3, |
|
||||
direction: 1, |
|
||||
); |
|
||||
|
|
||||
_drawOrbitingCloud( |
|
||||
canvas: canvas, |
|
||||
center: center, |
|
||||
time: time, |
|
||||
colors: [Colors.red, Colors.deepOrangeAccent], |
|
||||
orbitRadius: baseRadius * 0.5, |
|
||||
speed: 0.8, |
|
||||
direction: -1, |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
void _drawCircularPath(Canvas canvas, Offset center, double radius) { |
|
||||
final paint = Paint() |
|
||||
..color = Colors.red |
|
||||
..style = PaintingStyle.stroke |
|
||||
..strokeWidth = 2 |
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, 4); |
|
||||
|
|
||||
canvas.drawCircle(center, radius, paint); |
|
||||
} |
|
||||
|
|
||||
void _drawOrbitingCloud({ |
|
||||
required Canvas canvas, |
|
||||
required Offset center, |
|
||||
required double time, |
|
||||
required List<Color> colors, |
|
||||
required double orbitRadius, |
|
||||
required double speed, |
|
||||
required int direction, |
|
||||
}) { |
|
||||
final angle = time * speed * direction; |
|
||||
final position = Offset( |
|
||||
center.dx + orbitRadius * cos(angle), |
|
||||
center.dy + orbitRadius * sin(angle), |
|
||||
); |
|
||||
|
|
||||
final gradient = RadialGradient( |
|
||||
colors: colors, |
|
||||
stops: const [0.3, 1.0], |
|
||||
radius: 0.6, |
|
||||
focal: Alignment( |
|
||||
0.1 + cos(angle * 2) * 0.3, |
|
||||
0.1 + sin(angle * 2) * 0.3, |
|
||||
), |
|
||||
); |
|
||||
|
|
||||
final paint = Paint() |
|
||||
..shader = gradient.createShader(Rect.fromCircle( |
|
||||
center: position, |
|
||||
radius: 100 + 30 * sin(time * 3), |
|
||||
)) |
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, 40) |
|
||||
..blendMode = BlendMode.plus; |
|
||||
|
|
||||
canvas.drawCircle(position, 80 + 20 * cos(time * 2), paint); |
|
||||
} |
|
||||
|
|
||||
@override |
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true; |
|
||||
} |
|
||||
@ -1,92 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|
||||
|
|
||||
class WeatherDayCard extends StatelessWidget { |
|
||||
final Map? data; |
|
||||
final bool isDarkMode; |
|
||||
|
|
||||
const WeatherDayCard(this.data, this.isDarkMode, {super.key}); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Row( |
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Text( |
|
||||
data?['city'], |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 10.sp, |
|
||||
), |
|
||||
), |
|
||||
Text( |
|
||||
'${data?['temperature']}°', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 30.sp, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.end, |
|
||||
children: [ |
|
||||
Icon( |
|
||||
Icons.sunny, |
|
||||
color: isDarkMode ? Colors.white : Colors.amber[600]!, |
|
||||
size: 18.w, |
|
||||
), |
|
||||
Text( |
|
||||
'${data?['weatherCondition']},${data?['windDirection']}风', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 14.sp, |
|
||||
), |
|
||||
), |
|
||||
Row( |
|
||||
children: [ |
|
||||
Text( |
|
||||
'湿\n度', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 10.sp, |
|
||||
height: 1, |
|
||||
), |
|
||||
), |
|
||||
5.horizontalSpace, |
|
||||
Text( |
|
||||
'${data?['humidity']}', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 20.sp, |
|
||||
), |
|
||||
), |
|
||||
10.horizontalSpace, |
|
||||
Text( |
|
||||
'风\n力', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 10.sp, |
|
||||
height: 1, |
|
||||
), |
|
||||
), |
|
||||
5.horizontalSpace, |
|
||||
Text( |
|
||||
'${data?['windForce']}', |
|
||||
style: TextStyle( |
|
||||
color: isDarkMode ? Colors.white : Colors.black, |
|
||||
fontSize: 20.sp, |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
], |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue