You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
701 lines
20 KiB
701 lines
20 KiB
import 'package:get/get.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import 'dart:convert';
|
|
import '../../../data/services/volcano_ai_service.dart';
|
|
import '../../../data/services/volcano_tts_service.dart';
|
|
import '../models/message_model.dart';
|
|
import '../../../data/services/voice_recognition_service.dart';
|
|
import '../controllers/voice_input_controller.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
class ChatController extends GetxController {
|
|
String agentId = '';
|
|
String agentName = '';
|
|
String agentAvatar = '';
|
|
String? agentSubtitle;
|
|
String systemPrompt = '';
|
|
String welcomeMessage = '';
|
|
bool playVoiceOnEnter = false;
|
|
|
|
final VolcanoAIService _aiService = VolcanoAIService();
|
|
late final VolcanoTtsService _ttsService;
|
|
late final VoiceRecognitionService _recognitionService;
|
|
static const String _storagePrefix = 'chat_history_';
|
|
|
|
final messages = <Message>[].obs;
|
|
final isLoading = false.obs;
|
|
late final TextEditingController messageController;
|
|
late final ScrollController scrollController;
|
|
final _storage = GetStorage();
|
|
|
|
// 用于存储当前正在流式生成的消息
|
|
final RxString currentStreamMessage = ''.obs;
|
|
Message? _currentAssistantMessage;
|
|
String _pendingTtsText = '';
|
|
static const int _minTtsLength = 20;
|
|
bool _isDisposed = false;
|
|
|
|
// 语音输入相关状态
|
|
final isRecording = false.obs;
|
|
final recordingText = ''.obs;
|
|
final isVoiceInputVisible = false.obs;
|
|
final isVoiceConnecting = true.obs;
|
|
final isVoiceMuted = false.obs;
|
|
Message? _currentVoiceMessage;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
messageController = TextEditingController();
|
|
scrollController = ScrollController();
|
|
_initTtsService();
|
|
_initVoiceRecognitionService();
|
|
_initFromArguments();
|
|
_initializeController();
|
|
|
|
// 延迟生成问候语,确保页面已完全加载
|
|
if (playVoiceOnEnter) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_isDisposed) {
|
|
generateSimpleGreeting();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void _initFromArguments() {
|
|
try {
|
|
final arguments = Get.arguments;
|
|
if (arguments == null) {
|
|
throw Exception('No arguments provided to ChatController');
|
|
}
|
|
|
|
final args = arguments as Map<String, dynamic>;
|
|
agentId = args['agentId'] as String? ?? '';
|
|
agentName = args['agentName'] as String? ?? 'Assistant';
|
|
agentAvatar = args['agentAvatar'] as String? ?? 'assets/images/default_avatar.png';
|
|
agentSubtitle = args['agentSubtitle'] as String?;
|
|
systemPrompt = args['systemPrompt'] as String? ?? '';
|
|
welcomeMessage = args['welcomeMessage'] as String? ?? 'Hello! How can I help you today?';
|
|
playVoiceOnEnter = args['playVoiceOnEnter'] as bool? ?? false;
|
|
|
|
if (agentId.isEmpty) {
|
|
throw Exception('Invalid agent ID');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('🔴 Error initializing ChatController: $e');
|
|
Get.back(); // 返回上一页
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to initialize chat: Invalid parameters',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _initTtsService() {
|
|
_ttsService = Get.find<VolcanoTtsService>();
|
|
_ttsService.isEnabled.value = true;
|
|
}
|
|
|
|
void _initVoiceRecognitionService() {
|
|
_recognitionService = Get.find<VoiceRecognitionService>();
|
|
_setupVoiceRecognitionCallbacks();
|
|
}
|
|
|
|
void _setupVoiceRecognitionCallbacks() {
|
|
_recognitionService.onRecognitionResult = (result) {
|
|
if (isVoiceMuted.value) return;
|
|
recordingText.value = result;
|
|
handleRecognizing(result);
|
|
};
|
|
|
|
_recognitionService.onError = (error) {
|
|
// 不要立即显示错误,先尝试重新连接
|
|
_handleRecognitionError(error);
|
|
};
|
|
|
|
_recognitionService.onRecognitionComplete = () {
|
|
stopRecording();
|
|
if (!isVoiceMuted.value && recordingText.value.isNotEmpty) {
|
|
handleVoiceResult(recordingText.value);
|
|
}
|
|
};
|
|
|
|
_recognitionService.onConnectionStatusChanged = (status) {
|
|
if (status == 'disconnected' && isRecording.value) {
|
|
// 如果在录音过程中断开连接,尝试重新连接
|
|
_handleWebSocketDisconnection();
|
|
}
|
|
};
|
|
|
|
// 添加句子完成的回调
|
|
_recognitionService.onSentenceComplete = (text) {
|
|
if (!isVoiceMuted.value && text.isNotEmpty) {
|
|
handleVoiceResult(text);
|
|
// 继续录音,等待下一句话
|
|
if (isVoiceInputVisible.value) {
|
|
startRecording();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
Future<void> _handleRecognitionError(String error) async {
|
|
// 如果是权限相关错误,请求权限
|
|
if (error.toLowerCase().contains('permission')) {
|
|
try {
|
|
const platform = MethodChannel('com.example.deep_voice/audio');
|
|
await platform.invokeMethod('requestAudioPermissions');
|
|
// 权限请求后重试
|
|
if (isVoiceInputVisible.value) {
|
|
await Future.delayed(const Duration(seconds: 1));
|
|
startRecording();
|
|
}
|
|
return;
|
|
} catch (e) {
|
|
debugPrint('Failed to request permissions: $e');
|
|
}
|
|
}
|
|
|
|
// 如果是连接错误,尝试重新连接
|
|
if (error.toLowerCase().contains('connection') || error.toLowerCase().contains('websocket')) {
|
|
_handleWebSocketDisconnection();
|
|
return;
|
|
}
|
|
|
|
// 其他错误显示给用户
|
|
Get.snackbar(
|
|
'Error',
|
|
'语音识别失败: $error',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
stopVoiceInput();
|
|
}
|
|
|
|
Future<void> _handleWebSocketDisconnection() async {
|
|
if (!isVoiceInputVisible.value) return;
|
|
|
|
isVoiceConnecting.value = true;
|
|
stopRecording();
|
|
|
|
// 保存当前的语音文本
|
|
final currentText = recordingText.value;
|
|
|
|
try {
|
|
// 等待一段时间后重试
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
if (!isVoiceInputVisible.value) return;
|
|
|
|
// 重新初始化语音识别
|
|
await _recognitionService.onInit();
|
|
|
|
// 恢复之前的状态
|
|
if (currentText.isNotEmpty) {
|
|
recordingText.value = currentText;
|
|
}
|
|
|
|
isVoiceConnecting.value = false;
|
|
if (isVoiceInputVisible.value) {
|
|
startRecording();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to reconnect: $e');
|
|
Get.snackbar(
|
|
'Error',
|
|
'重新连接失败,请稍后再试',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
stopVoiceInput();
|
|
}
|
|
}
|
|
|
|
Future<void> _initializeController() async {
|
|
try {
|
|
await _loadChatHistory();
|
|
|
|
await _showWelcomeMessages();
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to initialize chat: $e',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _showWelcomeMessages() async {
|
|
try {
|
|
if (messages.isEmpty && agentName.isNotEmpty && playVoiceOnEnter) {
|
|
// 只有 playVoiceOnEnter 为 true 时才生成动态问候语
|
|
await _generatePersonalizedGreeting();
|
|
// 保存聊天记录并立即滚动到底部
|
|
await _saveChatHistory();
|
|
_scrollToBottom();
|
|
}
|
|
} catch (e) {
|
|
print('显示欢迎消息失败: $e');
|
|
// 如果生成问候语失败,确保至少显示静态欢迎消息
|
|
if (_ttsService.isEnabled.value) {
|
|
_ttsService.speak(welcomeMessage);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _generatePersonalizedGreeting() async {
|
|
if (_isDisposed) return;
|
|
|
|
String fullGreeting = '';
|
|
try {
|
|
currentStreamMessage.value = '';
|
|
_currentAssistantMessage = Message(
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: DateTime.now(),
|
|
);
|
|
messages.add(_currentAssistantMessage!);
|
|
|
|
// 构建用于生成个性化问候语的消息列表
|
|
final greetingMessages = [
|
|
{'role': 'system', 'content': systemPrompt},
|
|
{
|
|
'role': 'user',
|
|
'content': '请根据你的身份和性格,用一句简短的话向我打个招呼,要体现你的特点,不要超过18个字。'
|
|
},
|
|
];
|
|
|
|
await for (final chunk in _aiService.sendMessageStream(
|
|
messages: greetingMessages,
|
|
systemPrompt: systemPrompt,
|
|
)) {
|
|
if (_isDisposed) break;
|
|
|
|
// 更新消息内容
|
|
currentStreamMessage.value += chunk;
|
|
_updateAssistantMessage(currentStreamMessage.value);
|
|
fullGreeting += chunk;
|
|
}
|
|
|
|
// 完整问候语生成完毕后,一次性播放
|
|
if (!_isDisposed &&
|
|
_ttsService.isEnabled.value &&
|
|
fullGreeting.isNotEmpty) {
|
|
await _ttsService.speak(fullGreeting);
|
|
}
|
|
} catch (e) {
|
|
print('生成问候语失败: $e');
|
|
// 移除未完成的消息
|
|
if (_currentAssistantMessage != null) {
|
|
messages.remove(_currentAssistantMessage);
|
|
}
|
|
} finally {
|
|
if (!_isDisposed) {
|
|
_currentAssistantMessage = null;
|
|
currentStreamMessage.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_isDisposed = true;
|
|
|
|
// 确保语音输入被停止
|
|
stopVoiceInput();
|
|
|
|
// 先停止所有正在进行的操作
|
|
_ttsService.stop();
|
|
currentStreamMessage.value = '';
|
|
_currentAssistantMessage = null;
|
|
_pendingTtsText = '';
|
|
|
|
// 最后再处理控制器的销毁
|
|
try {
|
|
messageController.dispose();
|
|
} catch (e) {
|
|
debugPrint('Error disposing messageController: $e');
|
|
}
|
|
|
|
try {
|
|
scrollController.dispose();
|
|
} catch (e) {
|
|
debugPrint('Error disposing scrollController: $e');
|
|
}
|
|
|
|
super.onClose();
|
|
}
|
|
|
|
Future<void> _loadChatHistory() async {
|
|
try {
|
|
final historyJson = _storage.read('${_storagePrefix}${agentId}');
|
|
if (historyJson != null) {
|
|
final List<dynamic> historyList = json.decode(historyJson);
|
|
messages.value = historyList.map((item) => Message.fromJson(item)).toList();
|
|
|
|
// 如果有历史消息,等待下一帧再滚动到底部
|
|
if (messages.isNotEmpty) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollToBottom();
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to load chat history: $e',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _saveChatHistory() async {
|
|
try {
|
|
final historyJson =
|
|
json.encode(messages.map((msg) => msg!.toJson()).toList());
|
|
await _storage.write('${_storagePrefix}${agentId}', historyJson);
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to save chat history: $e',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
void _scrollToBottom() {
|
|
if (scrollController.hasClients) {
|
|
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
|
}
|
|
}
|
|
|
|
void _updateAssistantMessage(String content) {
|
|
if (_currentAssistantMessage != null) {
|
|
final index = messages.indexWhere((msg) =>
|
|
msg.timestamp == _currentAssistantMessage?.timestamp &&
|
|
msg.role == _currentAssistantMessage?.role);
|
|
|
|
if (index != -1) {
|
|
messages[index] = Message(
|
|
role: 'assistant',
|
|
content: content,
|
|
timestamp: _currentAssistantMessage!.timestamp,
|
|
);
|
|
// 只在非初始化阶段滚动
|
|
if (!playVoiceOnEnter || content.isNotEmpty) {
|
|
_scrollToBottom();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void clearMessage() {
|
|
if (!_isDisposed && messageController.text.isNotEmpty) {
|
|
messageController.clear();
|
|
}
|
|
}
|
|
|
|
Future<void> sendMessage() async {
|
|
if (_isDisposed) return;
|
|
|
|
final text = messageController.text.trim();
|
|
if (text.isEmpty) return;
|
|
|
|
try {
|
|
final userMessage = Message(
|
|
role: 'user',
|
|
content: text,
|
|
timestamp: DateTime.now(),
|
|
);
|
|
messages.add(userMessage);
|
|
clearMessage();
|
|
await _saveChatHistory();
|
|
_scrollToBottom();
|
|
|
|
isLoading.value = true;
|
|
try {
|
|
currentStreamMessage.value = '';
|
|
_currentAssistantMessage = Message(
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: DateTime.now(),
|
|
);
|
|
messages.add(_currentAssistantMessage!);
|
|
_pendingTtsText = '';
|
|
|
|
await for (final chunk in _aiService.sendMessageStream(
|
|
messages: messages
|
|
.map((m) => {
|
|
'role': m.role,
|
|
'content': m.content,
|
|
})
|
|
.toList(),
|
|
systemPrompt: systemPrompt,
|
|
)) {
|
|
if (_isDisposed) break;
|
|
|
|
currentStreamMessage.value += chunk;
|
|
_updateAssistantMessage(currentStreamMessage.value);
|
|
|
|
// 累积文本并合成
|
|
_pendingTtsText += chunk;
|
|
if (!_isDisposed && _ttsService.isEnabled.value) {
|
|
// 检查是否达到最小长度
|
|
if (_pendingTtsText.length >= _minTtsLength) {
|
|
// 找到最后一个句子结束的位置
|
|
int lastSentenceEnd = _findLastSentenceEnd(_pendingTtsText);
|
|
if (lastSentenceEnd > 0) {
|
|
// 播放到最后一个句子结束的位置
|
|
String textToSpeak =
|
|
_pendingTtsText.substring(0, lastSentenceEnd + 1);
|
|
_ttsService.speak(textToSpeak);
|
|
// 保留剩余的文本
|
|
_pendingTtsText = _pendingTtsText.substring(lastSentenceEnd + 1);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 处理剩余的文本
|
|
if (!_isDisposed &&
|
|
_ttsService.isEnabled.value &&
|
|
_pendingTtsText.isNotEmpty) {
|
|
_ttsService.speak(_pendingTtsText);
|
|
}
|
|
_pendingTtsText = '';
|
|
|
|
if (!_isDisposed) {
|
|
await _saveChatHistory();
|
|
}
|
|
} catch (e) {
|
|
if (!_isDisposed) {
|
|
if (_currentAssistantMessage != null) {
|
|
messages.remove(_currentAssistantMessage);
|
|
}
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to get response from AI: $e',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
} finally {
|
|
if (!_isDisposed) {
|
|
_currentAssistantMessage = null;
|
|
currentStreamMessage.value = '';
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print('发送消息失败: $e');
|
|
}
|
|
}
|
|
|
|
int _findLastSentenceEnd(String text) {
|
|
final sentenceEnds = [
|
|
text.lastIndexOf('。'),
|
|
text.lastIndexOf('!'),
|
|
text.lastIndexOf('?'),
|
|
text.lastIndexOf('.'),
|
|
text.lastIndexOf('!'),
|
|
text.lastIndexOf('?'),
|
|
];
|
|
|
|
return sentenceEnds.reduce((max, pos) => pos > max ? pos : max);
|
|
}
|
|
|
|
Future<void> clearChat() async {
|
|
try {
|
|
messages.clear();
|
|
await _storage.remove('${_storagePrefix}${agentId}');
|
|
await _showWelcomeMessages();
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to clear chat: $e',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
void toggleTTS() {
|
|
_ttsService.toggleEnabled();
|
|
}
|
|
|
|
/// 生成简单的问候语
|
|
Future<void> generateSimpleGreeting() async {
|
|
if (_isDisposed) return;
|
|
|
|
try {
|
|
currentStreamMessage.value = '';
|
|
_currentAssistantMessage = Message(
|
|
role: 'assistant',
|
|
content: '',
|
|
timestamp: DateTime.now(),
|
|
);
|
|
messages.add(_currentAssistantMessage!);
|
|
|
|
// 构建用于生成问候语的消息列表
|
|
final greetingMessages = [
|
|
{'role': 'system', 'content': systemPrompt},
|
|
{'role': 'user', 'content': '请用一句简短的话向我打个招呼,要体现你的特点,不要超过12个字。'},
|
|
];
|
|
|
|
String fullGreeting = '';
|
|
await for (final chunk in _aiService.sendMessageStream(
|
|
messages: greetingMessages,
|
|
systemPrompt: systemPrompt,
|
|
)) {
|
|
if (_isDisposed) break;
|
|
|
|
// 更新消息内容
|
|
currentStreamMessage.value += chunk;
|
|
_updateAssistantMessage(currentStreamMessage.value);
|
|
fullGreeting += chunk;
|
|
}
|
|
|
|
// 完整问候语生成完毕后,一次性播放
|
|
if (!_isDisposed && _ttsService.isEnabled.value && fullGreeting.isNotEmpty) {
|
|
await _ttsService.speak(fullGreeting);
|
|
}
|
|
} catch (e) {
|
|
print('生成问候语失败: $e');
|
|
// 移除未完成的消息
|
|
if (_currentAssistantMessage != null) {
|
|
messages.remove(_currentAssistantMessage);
|
|
}
|
|
} finally {
|
|
if (!_isDisposed) {
|
|
_currentAssistantMessage = null;
|
|
currentStreamMessage.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 开始语音输入
|
|
void startVoiceInput() {
|
|
isVoiceInputVisible.value = true;
|
|
isVoiceConnecting.value = true;
|
|
isRecording.value = false;
|
|
recordingText.value = '';
|
|
|
|
// 创建语音输入控制器
|
|
Get.put(VoiceInputController(
|
|
onRecordingResult: handleVoiceResult,
|
|
onClosePanel: stopVoiceInput,
|
|
onRecognizing: handleRecognizing,
|
|
));
|
|
|
|
// 初始化语音识别
|
|
_initializeVoiceRecognition();
|
|
}
|
|
|
|
Future<void> _initializeVoiceRecognition() async {
|
|
try {
|
|
await _recognitionService.onInit();
|
|
isVoiceConnecting.value = false;
|
|
if (isVoiceInputVisible.value) {
|
|
startRecording();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to initialize voice recognition: $e');
|
|
Get.snackbar(
|
|
'Error',
|
|
'初始化语音识别失败: $e',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
stopVoiceInput();
|
|
}
|
|
}
|
|
|
|
void startRecording() {
|
|
if (!isVoiceConnecting.value && isVoiceInputVisible.value) {
|
|
isRecording.value = true;
|
|
_recognitionService.startRecognition();
|
|
}
|
|
}
|
|
|
|
void stopRecording() {
|
|
if (isRecording.value) {
|
|
isRecording.value = false;
|
|
_recognitionService.stopRecognition();
|
|
}
|
|
}
|
|
|
|
void toggleMute() {
|
|
isVoiceMuted.value = !isVoiceMuted.value;
|
|
}
|
|
|
|
void stopVoiceInput() {
|
|
isVoiceInputVisible.value = false;
|
|
isVoiceConnecting.value = true;
|
|
isRecording.value = false;
|
|
recordingText.value = '';
|
|
|
|
// 停止语音识别服务
|
|
_recognitionService.stopRecognition();
|
|
|
|
// 如果存在临时语音消息但没有实际内容,则移除它
|
|
if (_currentVoiceMessage != null && _currentVoiceMessage!.content == '🎤 ...') {
|
|
messages.remove(_currentVoiceMessage);
|
|
}
|
|
_currentVoiceMessage = null;
|
|
|
|
// 删除语音输入控制器
|
|
try {
|
|
if (Get.isRegistered<VoiceInputController>()) {
|
|
Get.delete<VoiceInputController>();
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Error deleting VoiceInputController: $e');
|
|
}
|
|
}
|
|
|
|
void handleRecognizing(String text) {
|
|
if (text.isNotEmpty) {
|
|
// 如果还没有创建临时消息,创建一个
|
|
if (_currentVoiceMessage == null) {
|
|
_currentVoiceMessage = Message(
|
|
role: 'user',
|
|
content: '🎤 $text',
|
|
timestamp: DateTime.now(),
|
|
);
|
|
messages.add(_currentVoiceMessage!);
|
|
} else {
|
|
// 更新已存在的临时消息
|
|
final index = messages.indexOf(_currentVoiceMessage!);
|
|
if (index != -1) {
|
|
messages[index] = Message(
|
|
role: 'user',
|
|
content: '🎤 $text',
|
|
timestamp: _currentVoiceMessage!.timestamp,
|
|
);
|
|
}
|
|
}
|
|
_scrollToBottom();
|
|
}
|
|
}
|
|
|
|
void handleVoiceResult(String text) {
|
|
if (text.isNotEmpty) {
|
|
if (_currentVoiceMessage != null) {
|
|
final index = messages.indexOf(_currentVoiceMessage!);
|
|
if (index != -1) {
|
|
messages[index] = Message(
|
|
role: 'user',
|
|
content: text,
|
|
timestamp: _currentVoiceMessage!.timestamp,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 发送消息
|
|
messageController.text = text;
|
|
sendMessage();
|
|
messageController.clear();
|
|
|
|
_scrollToBottom();
|
|
}
|
|
// 不再在这里停止语音输入,让用户可以继续说下一句
|
|
_currentVoiceMessage = null;
|
|
}
|
|
}
|
|
|