From 4decfc566bb1f93b696245d28c470a4a6181f6cf Mon Sep 17 00:00:00 2001 From: wolfplus Date: Fri, 11 Apr 2025 15:07:04 +0100 Subject: [PATCH] add --- .cursorrules | 14 +- android/app/build.gradle.kts | 3 +- android/settings.gradle.kts | 6 +- lib/core/widgets/message_bubble.dart | 2 +- lib/data/services/chat_history_service.dart | 105 ++++-- .../services/open_ai_service_adapter.dart | 99 +++--- .../android_voice_interaction_service.dart | 15 +- .../ios_voice_interaction_service.dart | 8 +- .../services/voice_interaction_service.dart | 13 +- .../chat/controllers/chat_controller.dart | 300 ++++++++++++------ lib/modules/chat/models/message_model.dart | 20 +- lib/modules/chat/views/chat_view.dart | 2 +- .../splash/controllers/splash_controller.dart | 2 +- local_plugins/azure_speech/LICENSE | 0 .../azure_speech/ios/Classes/.gitkeep | 0 .../azure_speech/ios/azure_speech.podspec | 24 ++ local_plugins/chat_storage/LICENSE | 0 .../README.md | 12 +- .../android/build.gradle.kts | 2 +- .../chat_storage/android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 2 +- .../chat_storage/ChatStoragePlugin.kt} | 6 +- .../chat_storage/ios/Classes/.gitkeep | 0 .../chat_storage/ios/chat_storage.podspec | 23 ++ .../lib/chat_storage.dart} | 5 +- .../pubspec.yaml | 12 +- .../android/settings.gradle.kts | 1 - .../com/yunqiinnovation/jieli/BleService.kt | 2 +- local_plugins/open_ai_service/LICENSE | 0 .../open_ai_service/OpenAIService.kt | 141 ++++---- .../open_ai_service/OpenAIServicePlugin.kt | 169 +++++----- .../open_ai_service/SystemFunctionHandler.kt | 4 +- .../open_ai_service/ios/Classes/.gitkeep | 0 .../ios/Classes/OpenAIService.swift | 296 ++++++++++------- .../ios/open_ai_service.podspec | 23 ++ local_plugins/volcano_speech/LICENSE | 0 .../volcano_speech/ios/Classes/.gitkeep | 0 .../volcano_speech/ios/volcano_speech.podspec | 23 ++ pubspec.yaml | 4 +- test.json | 204 +++++++++--- 40 files changed, 991 insertions(+), 552 deletions(-) create mode 100644 local_plugins/azure_speech/LICENSE create mode 100644 local_plugins/azure_speech/ios/Classes/.gitkeep create mode 100644 local_plugins/azure_speech/ios/azure_speech.podspec create mode 100644 local_plugins/chat_storage/LICENSE rename local_plugins/{chat_storage_sqlite => chat_storage}/README.md (82%) rename local_plugins/{chat_storage_sqlite => chat_storage}/android/build.gradle.kts (96%) create mode 100644 local_plugins/chat_storage/android/settings.gradle.kts rename local_plugins/{chat_storage_sqlite => chat_storage}/android/src/main/AndroidManifest.xml (82%) rename local_plugins/{chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt => chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt} (98%) create mode 100644 local_plugins/chat_storage/ios/Classes/.gitkeep create mode 100644 local_plugins/chat_storage/ios/chat_storage.podspec rename local_plugins/{chat_storage_sqlite/lib/chat_storage_sqlite.dart => chat_storage/lib/chat_storage.dart} (98%) rename local_plugins/{chat_storage_sqlite => chat_storage}/pubspec.yaml (61%) delete mode 100644 local_plugins/chat_storage_sqlite/android/settings.gradle.kts create mode 100644 local_plugins/open_ai_service/LICENSE create mode 100644 local_plugins/open_ai_service/ios/Classes/.gitkeep create mode 100644 local_plugins/open_ai_service/ios/open_ai_service.podspec create mode 100644 local_plugins/volcano_speech/LICENSE create mode 100644 local_plugins/volcano_speech/ios/Classes/.gitkeep create mode 100644 local_plugins/volcano_speech/ios/volcano_speech.podspec diff --git a/.cursorrules b/.cursorrules index 6e3d3fd0c..a81ae52e0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -1,10 +1,10 @@ -1. 不要主动运行程序, 如flutter run -2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 -3. 不要使用print, 而是使用logger -4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget -5. 不要增加重试逻辑,出现失败直接返回错误 -6. 尽可能避免使用try catch -7. 尽可能避免打印日志,仅在出错时记录日志 +1. 请严格遵循 Effective Dart 的风格指南来编写代码 +2. 不要主动运行程序, 如flutter run +3. 不要改动android, iOS项目的配置信息,包括SDK, NDK, JAVA版本等 +4. 不要使用print, 而是使用logger, 尽可能避免打印日志,仅在出错时记录日志 +5. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget +6. 不要增加重试逻辑,出现失败直接返回错误 +7. 尽可能避免使用try catch 8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 9. 请尽可能使用中文回复问题 10. 重要:不要过度设计!! diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 009a8926c..e388c263b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -108,7 +108,8 @@ dependencies { implementation(project(":azure_speech")) implementation(project(":open_ai_service")) implementation(project(":volcano_speech")) - implementation(project(":chat_storage_sqlite")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":chat_storage")) // 添加新创建的SQLite聊天存储插件 + implementation(project(":jieli")) // 添加新创建的SQLite聊天存储插件 // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 74e47be3f..bf3d50c8a 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -34,10 +34,12 @@ include(":app") include(":azure_speech") include(":open_ai_service") include(":volcano_speech") -include(":chat_storage_sqlite") +include(":chat_storage") +include(":jieli") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android") -project(":chat_storage_sqlite").projectDir = file("../local_plugins/chat_storage_sqlite/android") +project(":chat_storage").projectDir = file("../local_plugins/chat_storage/android") +project(":jieli").projectDir = file("../local_plugins/jieli/android") diff --git a/lib/core/widgets/message_bubble.dart b/lib/core/widgets/message_bubble.dart index 7b125ea7c..0ddbd836e 100644 --- a/lib/core/widgets/message_bubble.dart +++ b/lib/core/widgets/message_bubble.dart @@ -52,7 +52,7 @@ class MessageBubble extends StatelessWidget { ), // 加载指示器 - if (message.isLoading) + if (message.isIntermediate) Padding( padding: EdgeInsets.only(top: message.content.isNotEmpty ? 8.h : 0), child: _buildLoadingIndicator(), diff --git a/lib/data/services/chat_history_service.dart b/lib/data/services/chat_history_service.dart index 8211075fc..8749c5714 100644 --- a/lib/data/services/chat_history_service.dart +++ b/lib/data/services/chat_history_service.dart @@ -1,16 +1,13 @@ import 'dart:convert'; import 'package:get/get.dart'; -import 'package:get_storage/get_storage.dart'; import '../../modules/chat/models/message_model.dart'; import '../../core/utils/logger.dart'; +import 'package:chat_storage/chat_storage.dart'; /// 聊天记录管理服务,负责保存和加载聊天记录 class ChatHistoryService extends GetxService { - final GetStorage _storage = GetStorage(); - - // 存储键名 - static const String _historyKeyPrefix = 'chat_history_'; - static const int _maxHistoryCount = 10; // 每个对话最多保存的消息数 + // 最大历史记录数量 + static const int _maxHistoryCount = 200; // 每个对话最多保存的消息数 @override void onInit() { @@ -18,50 +15,95 @@ class ChatHistoryService extends GetxService { Logger.info('ChatHistoryService 已初始化'); } - /// 生成历史记录存储键 - String _getHistoryKey(String agentId) => '$_historyKeyPrefix$agentId'; - /// 保存聊天记录 /// /// [agentId] 代理ID /// [messages] 消息列表 Future saveHistory(String agentId, List messages) async { try { - // 过滤掉加载中的消息 - final filteredMessages = messages.where((m) => !m.isLoading).toList(); + if (messages.isEmpty) return; - // 如果消息超过最大数量,只保留最新的消息 - final messagesToSave = filteredMessages.length > _maxHistoryCount - ? filteredMessages.sublist(filteredMessages.length - _maxHistoryCount) - : filteredMessages; + // 获取最新的消息 + final latestMessage = messages.last; - // 转换为JSON - final jsonList = messagesToSave.map((m) => m.toJson()).toList(); - final jsonString = jsonEncode(jsonList); + // 如果是加载中的消息则跳过 + if (latestMessage.isIntermediate) return; - // 保存到存储 - await _storage.write(_getHistoryKey(agentId), jsonString); - // Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); - + // 直接插入最新的消息到数据库 + await ChatStorage.saveMessage( + sessionId: agentId, + message: latestMessage.content, + sender: latestMessage.role, + ); + + // 检查并清理过多的历史记录 + await _cleanupOldMessages(agentId); } catch (e) { Logger.error('保存聊天记录失败', e); } } + /// 清理旧消息,保持每个会话的消息数量不超过最大限制 + Future _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 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 /// 返回消息列表,如果没有找到或解析错误则返回空列表 - List loadHistory(String agentId) { + Future> loadHistory(String agentId) async { try { - final jsonString = _storage.read(_getHistoryKey(agentId)); + final pagedResult = await ChatStorage.getMessages( + sessionId: agentId, + page: 1, + pageSize: _maxHistoryCount, + ); - if (jsonString == null) { + if (pagedResult.messages.isEmpty) { return []; } - final jsonList = jsonDecode(jsonString) as List; - final messages = jsonList.map((json) => Message.fromJson(json)).toList(); + // 将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) { @@ -73,7 +115,7 @@ class ChatHistoryService extends GetxService { /// 清除特定代理的聊天记录 Future clearHistory(String agentId) async { try { - await _storage.remove(_getHistoryKey(agentId)); + await ChatStorage.deleteMessages(sessionId: agentId); Logger.info('已清除聊天记录,代理: $agentId'); } catch (e) { Logger.error('清除聊天记录失败', e); @@ -83,12 +125,7 @@ class ChatHistoryService extends GetxService { /// 清除所有聊天记录 Future clearAllHistory() async { try { - final keys = _storage.getKeys(); - for (final key in keys) { - if (key.startsWith(_historyKeyPrefix)) { - await _storage.remove(key); - } - } + await ChatStorage.clearDatabase(); Logger.info('已清除所有聊天记录'); } catch (e) { Logger.error('清除所有聊天记录失败', e); diff --git a/lib/data/services/open_ai_service_adapter.dart b/lib/data/services/open_ai_service_adapter.dart index 7eb337605..b93ce6cf0 100644 --- a/lib/data/services/open_ai_service_adapter.dart +++ b/lib/data/services/open_ai_service_adapter.dart @@ -4,12 +4,22 @@ import 'package:open_ai_service/open_ai_service.dart'; import 'package:get/get.dart'; import 'ai_service.dart'; +/// 定义流事件类型,用于区分不同类型的事件 +enum StreamEventType { token, complete, error } + +/// 流事件包装类 +class StreamEvent { + final StreamEventType type; + final String? content; + + StreamEvent(this.type, {this.content}); +} + /// OpenAI服务适配器 - 连接AiService接口与OpenAIService插件 class OpenAIServiceAdapter implements AiService { final OpenAIService _openAIService = OpenAIService(); StreamSubscription? _eventSubscription; - final StreamController _tokenStreamController = StreamController.broadcast(); - bool _isProcessingStream = false; + final StreamController _tokenStreamController = StreamController.broadcast(); /// 构造函数 OpenAIServiceAdapter() { @@ -26,46 +36,51 @@ class OpenAIServiceAdapter implements AiService { // 设置事件处理 _eventSubscription = _openAIService.eventStream.listen( (event) { - if (!_isProcessingStream) return; - try { switch (event.type) { case OpenAIEventType.token: if (event.content is String) { - _tokenStreamController.add(event.content as String); + _tokenStreamController.add(StreamEvent( + StreamEventType.token, + content: event.content as String + )); } else { printInfo(info: '收到非字符串类型的token: ${event.content}'); } break; case OpenAIEventType.complete: - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent(StreamEventType.complete)); break; case OpenAIEventType.error: - _tokenStreamController.addError('未知错误: ${event.content}'); - - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '未知错误: ${event.content}' + )); break; case OpenAIEventType.functionCall: - _tokenStreamController.addError('收到函数调用,该流仅支持文本响应'); - - - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '收到函数调用,该流仅支持文本响应' + )); break; } } catch (e) { printError(info: '处理事件出错: $e'); - _tokenStreamController.addError('处理事件失败: $e'); - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '处理事件失败: $e' + )); } }, onError: (error) { printError(info: '事件流错误: $error'); - _tokenStreamController.addError('事件流错误: $error'); - _isProcessingStream = false; + _tokenStreamController.add(StreamEvent( + StreamEventType.error, + content: '事件流错误: $error' + )); }, onDone: () { printInfo(info: '事件流已关闭'); - _isProcessingStream = false; }, ); } catch (e) { @@ -147,20 +162,28 @@ class OpenAIServiceAdapter implements AiService { // 创建用于接收token的控制器 final localController = StreamController(); - // 标记开始处理流 - _isProcessingStream = true; - // 添加从广播流到本地流的订阅 final subscription = _tokenStreamController.stream.listen( - (token) => localController.add(token), - onError: (error) { - printError(info: '令牌流错误: $error'); - localController.addError(error); - localController.close(); - }, - onDone: () { - printInfo(info: '令牌流已完成'); - localController.close(); + (streamEvent) { + switch (streamEvent.type) { + case StreamEventType.token: + if (streamEvent.content != null && !localController.isClosed) { + localController.add(streamEvent.content!); + } + break; + case StreamEventType.complete: + if (!localController.isClosed) { + localController.close(); + } + break; + case StreamEventType.error: + if (!localController.isClosed) { + printError(info: '令牌流错误: ${streamEvent.content}'); + localController.addError(streamEvent.content ?? '未知错误'); + localController.close(); + } + break; + } } ); @@ -177,17 +200,19 @@ class OpenAIServiceAdapter implements AiService { ); } catch (e) { printError(info: '启动消息流失败: $e'); - localController.addError('启动消息流失败: $e'); - localController.close(); - _isProcessingStream = false; + if (!localController.isClosed) { + localController.addError('启动消息流失败: $e'); + localController.close(); + } throw '启动消息流失败: $e'; } if (!started) { printError(info: '无法启动消息流'); - localController.addError('无法启动消息流'); - localController.close(); - _isProcessingStream = false; + if (!localController.isClosed) { + localController.addError('无法启动消息流'); + localController.close(); + } throw '无法启动消息流'; } @@ -224,7 +249,6 @@ class OpenAIServiceAdapter implements AiService { /// 释放资源 void dispose() { try { - _isProcessingStream = false; _eventSubscription?.cancel(); _tokenStreamController.close(); printInfo(info: 'OpenAIServiceAdapter资源已释放'); @@ -232,5 +256,4 @@ class OpenAIServiceAdapter implements AiService { printError(info: '释放资源时出错: $e'); } } - } \ No newline at end of file diff --git a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart index 5a3831d79..f4771ffac 100644 --- a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart @@ -267,12 +267,12 @@ class AndroidVoiceInteractionService extends GetxService { } /// 保存聊天记录 - void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { + Future _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) async { try { - // 获取ChatHistoryService实例 - final chatHistoryService = Get.find(); + if (userMessage.isEmpty) { + return; + } - // 创建用户消息和助手消息 final userMsg = Message( role: 'user', content: userMessage, @@ -285,14 +285,17 @@ class AndroidVoiceInteractionService extends GetxService { timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 ); + // 获取ChatHistoryService实例 + final chatHistoryService = Get.find(); + // 加载现有历史记录 - final existingMessages = chatHistoryService.loadHistory(agentId); + final existingMessages = await chatHistoryService.loadHistory(agentId); // 添加新消息 existingMessages.addAll([userMsg, assistantMsg]); // 保存更新后的历史记录 - chatHistoryService.saveHistory(agentId, existingMessages); + await chatHistoryService.saveHistory(agentId, existingMessages); } catch (e) { Logger.error('保存聊天记录失败: $e'); } diff --git a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart index 2d129b050..e9c9e1410 100644 --- a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart @@ -250,7 +250,7 @@ class IosVoiceInteractionService extends GetxService { ); // 保存聊天历史 - _saveChatHistory(userMsg, assistantMsg); + await _saveChatHistory(userMsg, assistantMsg); // 发送聊天历史事件 _sendChatHistoryEvent(userMsg.content, assistantMsg.content); @@ -424,16 +424,16 @@ class IosVoiceInteractionService extends GetxService { } // 保存聊天历史 - void _saveChatHistory(Message userMsg, Message assistantMsg) { + Future _saveChatHistory(Message userMsg, Message assistantMsg) async { try { // 加载现有历史记录 - final existingMessages = _chatHistoryService.loadHistory(_agentId); + final existingMessages = await _chatHistoryService.loadHistory(_agentId); // 添加新消息 existingMessages.addAll([userMsg, assistantMsg]); // 保存更新后的历史记录 - _chatHistoryService.saveHistory(_agentId, existingMessages); + await _chatHistoryService.saveHistory(_agentId, existingMessages); Logger.info('保存聊天历史记录成功'); } catch (e) { diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 57e8636c5..6adafa5c4 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -107,7 +107,7 @@ class VoiceInteractionService extends GetxService { _eventStreamController.add(chatHistoryEvent); // 保存聊天历史到ChatHistoryService - _saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); + _saveChatHistory(agentId, userMessage, assistantMessage); break; case 'enter_translation_mode': @@ -132,12 +132,9 @@ class VoiceInteractionService extends GetxService { } /// 保存聊天历史 - void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { + Future _saveChatHistory(String agentId, String userMessage, String assistantMessage) async { try { - // 检查参数有效性 - if (userMessage.isEmpty) { - return; - } + final timestamp = DateTime.now().millisecondsSinceEpoch; // 创建用户消息和助手消息 final userMsg = Message( @@ -153,13 +150,13 @@ class VoiceInteractionService extends GetxService { ); // 加载现有历史记录 - final existingMessages = _chatHistoryService.loadHistory(agentId); + final existingMessages = await _chatHistoryService.loadHistory(agentId); // 添加新消息 existingMessages.addAll([userMsg, assistantMsg]); // 保存更新后的历史记录 - _chatHistoryService.saveHistory(agentId, existingMessages); + await _chatHistoryService.saveHistory(agentId, existingMessages); Logger.info('已保存聊天历史: agentId=$agentId'); } catch (e) { diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 77fa84a9f..99073e715 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -2,18 +2,17 @@ import 'dart:async'; import 'package:get/get.dart'; import 'package:flutter/widgets.dart'; import 'package:get_storage/get_storage.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/volcano_ai_service.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/chat_history_service.dart'; import '../../../data/models/events/voice_interaction_event.dart'; import '../../../data/services/voice_interaction_service.dart'; import '../../../data/services/open_ai_service_adapter.dart'; @@ -23,7 +22,6 @@ class ChatController extends GetxController { late AiService _aiService; late TtsService _ttsService; final AsrService _asrService = Get.find(); - final ChatHistoryService _historyService = Get.find(); final VoiceInteractionService _voiceInteractionService = Get.find(); @@ -45,6 +43,13 @@ class ChatController extends GetxController { 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; @@ -53,9 +58,6 @@ class ChatController extends GetxController { final RxBool isListening = false.obs; final RxBool isSpeechDetected = false.obs; - // 临时消息索引,用于更新临时消息 - int _tempMessageIndex = -1; - // 防抖计时器 Timer? _scrollDebounceTimer; @@ -107,14 +109,9 @@ class ChatController extends GetxController { break; default: // 默认使用OpenAIServiceAdapter - try { _aiService = Get.find(); Logger.info('使用OpenAIServiceAdapter'); - } catch (e) { - // 如果找不到OpenAIServiceAdapter,则回退到VolcanoAIService - Logger.info('未找到OpenAIServiceAdapter,回退使用VolcanoAIService: $e'); - _aiService = Get.find(); - } + } // 使用克隆音色语音合成 @@ -136,8 +133,11 @@ class ChatController extends GetxController { // 设置TTS服务 await _ttsService.setVoice(agent.voice); - // 加载聊天历史 - _loadChatHistory(); + // 添加滚动监听器,用于触发加载更多 + scrollController.addListener(_scrollListener); + + // 加载聊天历史 (加载第一页) + await _loadChatHistory(); // 如果没有历史记录,添加欢迎消息 if (messages.isEmpty && agent.welcomeMessage.isNotEmpty) { @@ -213,7 +213,9 @@ class ChatController extends GetxController { messages.add(userMsg); messages.add(assistantMsg); } - _scrollToBottom(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } /// 停止当前交互 @@ -239,6 +241,7 @@ class ChatController extends GetxController { Logger.info('ChatController onClose'); // 清理资源 + scrollController.removeListener(_scrollListener); // 移除监听器 scrollController.dispose(); _aiResponseSubscription?.cancel(); _recognitionSubscription?.cancel(); @@ -255,7 +258,7 @@ class ChatController extends GetxController { // 保存当前聊天记录 if (agent.id != null) { - _historyService.saveHistory(agent.id!, messages.toList()); + // _historyService.saveHistory(agent.id!, messages.toList()); } super.onClose(); @@ -289,11 +292,11 @@ class ChatController extends GetxController { _aiResponseSubscription!.cancel(); _aiResponseSubscription = null; - // 将加载中的消息转换为正常消息,保留已输出的内容 - final index = messages.indexWhere((m) => m.isLoading); + // 将AI中间消息转换为正常消息,保留已输出的内容 + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { messages[index] = messages[index].copyWith( - isLoading: false, + isIntermediate: false, ); messages.refresh(); } @@ -323,10 +326,10 @@ class ChatController extends GetxController { // 清空输入框 inputText.value = ''; - // 添加加载中的消息 - final loadingMessage = Message.loading(); - messages.add(loadingMessage); - + // 添加中间状态的AI消息 + final intermediateMessage = Message.intermediate('assistant'); + messages.add(intermediateMessage); + messages.refresh(); WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); @@ -355,8 +358,8 @@ class ChatController extends GetxController { fullResponse += chunk; - // 更新加载中的消息内容 - _updateLoadingMessage(fullResponse); + // 更新中间状态的消息内容 + _updateIntermediateMessage(fullResponse); // 如果TTS启用,使用流式处理发送到TTS if (isTtsEnabled.value) { @@ -366,21 +369,18 @@ class ChatController extends GetxController { onDone: () { if (_shouldCancelAiResponse) return; - // 完成响应,将isLoading设为false + // 完成响应,将isIntermediate设为false _finalizeResponse(fullResponse); // 如果TTS启用,处理缓冲区中的剩余文本 if (isTtsEnabled.value) { _ttsService.flushStream(); } - - // 保存聊天历史 - _saveChatHistory(); }, onError: (error) { Logger.error('AI响应错误', error); - // 更新加载中的消息为错误消息 + // 更新中间状态的消息为错误消息 _handleResponseError(); }, ); @@ -396,7 +396,7 @@ class ChatController extends GetxController { final List> messageHistory = []; // 只取最近的10条消息作为上下文 - final recentMessages = messages.where((m) => !m.isLoading).toList(); + final recentMessages = messages.where((m) => !m.isIntermediate).toList(); final startIndex = recentMessages.length > 10 ? recentMessages.length - 10 : 0; @@ -411,42 +411,61 @@ class ChatController extends GetxController { return messageHistory; } - // 更新加载中的消息 - void _updateLoadingMessage(String content) { - final index = messages.indexWhere((m) => m.isLoading); + // 更新中间状态的消息 + void _updateIntermediateMessage(String content) { + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { messages[index] = messages[index].copyWith( content: content, - isLoading: true, ); messages.refresh(); _scrollToBottom(); } } - // 完成响应 + // 更新或添加最终响应 void _finalizeResponse(String content) { - final index = messages.indexWhere((m) => m.isLoading); + // 查找AI的中间状态消息 + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { + // 更新中间状态消息为最终状态 messages[index] = messages[index].copyWith( content: content, - isLoading: false, + isIntermediate: false, + timestamp: DateTime.now(), // 更新时间戳为最终时间 ); - messages.refresh(); - _scrollToBottom(); + // 保存最终的助手消息到数据库 + _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; + _aiResponseSubscription = null; // 清理订阅 + _shouldCancelAiResponse = false; // 重置取消标志 + + // 响应完成后,重新启动语音识别的自动停止计时器 + if (_isVoiceInteractionMode) { + _startAutoStopTimer(); + } } // 处理响应错误 void _handleResponseError() { - final index = messages.indexWhere((m) => m.isLoading); + // 查找AI的中间状态消息 + final index = messages.indexWhere((m) => m.isIntermediate && m.role == 'assistant'); if (index != -1) { messages[index] = messages[index].copyWith( content: '抱歉,我遇到了一些问题,无法回应您的请求。', - isLoading: false, + isIntermediate: false, ); messages.refresh(); } @@ -479,9 +498,6 @@ class ChatController extends GetxController { isListening.value = true; recognizedText.value = ''; - // 重置临时消息索引 - _tempMessageIndex = -1; - // 启动自动停止计时器 _startAutoStopTimer(); @@ -573,24 +589,23 @@ class ChatController extends GetxController { _interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); } - // 更新或创建临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - // 更新已有的临时消息 - messages[_tempMessageIndex] = messages[_tempMessageIndex].copyWith( + // 查找或创建用户的中间状态消息 + final intermediateIndex = messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); + + if (intermediateIndex >= 0) { + // 更新已有的中间状态消息 + messages[intermediateIndex] = messages[intermediateIndex].copyWith( content: text, ); messages.refresh(); } else { - // 添加新的临时消息 - final tempMessage = Message( - role: 'user', - content: text, - timestamp: DateTime.now(), - ); - messages.add(tempMessage); - _tempMessageIndex = messages.length - 1; + // 添加新的中间状态消息 + var intermediateMessage = Message.intermediate('user'); + intermediateMessage = intermediateMessage.copyWith(content: text); + messages.add(intermediateMessage); messages.refresh(); } + WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); }); @@ -606,14 +621,12 @@ class ChatController extends GetxController { // 更新活动时间 _updateActivityTime(); - // 查找并移除临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - messages.removeAt(_tempMessageIndex); + // 查找并移除用户的中间状态消息 + final intermediateIndex = messages.indexWhere((m) => m.isIntermediate && m.role == 'user'); + if (intermediateIndex >= 0) { + messages.removeAt(intermediateIndex); } - // 重置临时消息索引 - _tempMessageIndex = -1; - // 发送识别到的文本 sendMessage(text); @@ -640,16 +653,20 @@ class ChatController extends GetxController { // 清理语音识别资源 void _cleanupRecognition() { - // 查找并移除临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - if (messages[_tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(_tempMessageIndex); + // 查找并移除用户的中间状态消息 + 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(); } - // 重置临时消息索引 - _tempMessageIndex = -1; - // 停止语音识别 isVoiceRecognitionActive.value = false; isListening.value = false; @@ -671,15 +688,20 @@ class ChatController extends GetxController { recognizedText.value = ''; isSpeechDetected.value = false; - // 查找并移除临时消息 - if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { - if (messages[_tempMessageIndex].content.trim().isEmpty) { - messages.removeAt(_tempMessageIndex); + // 查找并处理用户的中间状态消息 + 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(); } - - // 重置临时消息索引 - _tempMessageIndex = -1; } catch (e) { Logger.error('停止语音识别失败', e); } @@ -688,7 +710,7 @@ class ChatController extends GetxController { // 清空聊天记录 void clearChat() { messages.clear(); - + _ttsService.stop(); // 添加欢迎消息 if (agent.welcomeMessage.isNotEmpty) { messages.add(Message( @@ -697,31 +719,119 @@ class ChatController extends GetxController { timestamp: DateTime.now(), )); } + // 删除数据库中的历史记录 + ChatStorage.deleteMessages(sessionId: agent.id).catchError((e) { + Logger.error('清空数据库聊天记录失败: $e'); + }); + } + + // 滚动监听,用于触发加载更多历史记录 + void _scrollListener() { + // 当滚动到顶部并且有更多历史记录且不在加载中时,加载更多 + if (scrollController.position.pixels == scrollController.position.minScrollExtent && + _hasMoreHistory.value && + !_isLoadingMore.value) { + _loadMoreHistory(); + } + } + + // 加载聊天历史 (首次加载或刷新) + Future _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; - // 删除存储的历史记录 - _historyService.clearHistory(agent.id); + final historyMessages = pagedResult.messages.map((chatMsg) { + return Message( + role: chatMsg.sender, + content: chatMsg.message, + timestamp: DateTime.parse(chatMsg.timestamp), + ); + }).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; + } } - // 加载聊天历史 - void _loadChatHistory() { - // 从历史服务加载 - final history = _historyService.loadHistory(agent.id); - if (history.isNotEmpty) { - messages.clear(); - messages.addAll(history); + // 加载更多历史记录 + Future _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), + ); + }).toList(); + + // 将旧消息插入到列表顶部 + messages.insertAll(0, olderMessages.reversed); + + } catch (e) { + Logger.error('加载更多聊天历史失败', e); + _currentPage.value--; // 加载失败,恢复页码 + } finally { + _isLoadingMore.value = false; } } - // 保存聊天历史 - void _saveChatHistory() { - _historyService.saveHistory(agent.id, messages); + // 保存单条聊天消息到数据库 + Future _saveMessageToDb(Message message) async { + if (message.isIntermediate) return; // 不保存中间状态的消息 + try { + await ChatStorage.saveMessage( + sessionId: agent.id, + message: message.content, + sender: message.role, + ); + } catch (e) { + Logger.error('保存消息到数据库失败', e); + } } - // 添加消息,供外部调用 + // 添加消息,供外部调用 (如用户发送消息、AI回复) void addMessage(Message message) { messages.add(message); - _saveChatHistory(); - _scrollToBottom(); + _saveMessageToDb(message); // 保存到数据库 + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } void _stopVoiceRecognitionAutomatically() { diff --git a/lib/modules/chat/models/message_model.dart b/lib/modules/chat/models/message_model.dart index 6307989ae..001853834 100644 --- a/lib/modules/chat/models/message_model.dart +++ b/lib/modules/chat/models/message_model.dart @@ -2,13 +2,13 @@ class Message { final String role; // 'user' or 'assistant' final String content; final DateTime timestamp; - final bool isLoading; + final bool isIntermediate; // 中间状态标记,替代isLoading Message({ required this.role, required this.content, required this.timestamp, - this.isLoading = false, + this.isIntermediate = false, // 默认不是中间状态 }); // 从JSON构造函数 @@ -17,7 +17,7 @@ class Message { role: json['role'] as String, content: json['content'] as String, timestamp: DateTime.parse(json['timestamp'] as String), - isLoading: json['isLoading'] as bool? ?? false, + isIntermediate: json['isIntermediate'] as bool? ?? false, ); } @@ -27,17 +27,17 @@ class Message { 'role': role, 'content': content, 'timestamp': timestamp.toIso8601String(), - 'isLoading': isLoading, + 'isIntermediate': isIntermediate, }; } - // 创建一个加载中的消息 - factory Message.loading() { + // 创建一个中间状态的消息 + factory Message.intermediate(String role) { return Message( - role: 'assistant', + role: role, content: '', timestamp: DateTime.now(), - isLoading: true, + isIntermediate: true, ); } @@ -46,13 +46,13 @@ class Message { String? role, String? content, DateTime? timestamp, - bool? isLoading, + bool? isIntermediate, }) { return Message( role: role ?? this.role, content: content ?? this.content, timestamp: timestamp ?? this.timestamp, - isLoading: isLoading ?? this.isLoading, + isIntermediate: isIntermediate ?? this.isIntermediate, ); } } \ No newline at end of file diff --git a/lib/modules/chat/views/chat_view.dart b/lib/modules/chat/views/chat_view.dart index 86d77e3a1..cfd64290d 100644 --- a/lib/modules/chat/views/chat_view.dart +++ b/lib/modules/chat/views/chat_view.dart @@ -312,7 +312,7 @@ class ChatView extends GetView { ), // 加载中动画 - if (message.isLoading) + if (message.isIntermediate) Padding( padding: EdgeInsets.only(top: 8.h), child: Row( diff --git a/lib/modules/splash/controllers/splash_controller.dart b/lib/modules/splash/controllers/splash_controller.dart index b5dd9d823..c9a7c38d1 100644 --- a/lib/modules/splash/controllers/splash_controller.dart +++ b/lib/modules/splash/controllers/splash_controller.dart @@ -34,7 +34,7 @@ class SplashController extends GetxController { // 进度条动画(持续2秒) void _startLoadingAnimation() { - const totalDuration = 2000; // 动画总时长2秒 + const totalDuration = 500; // 动画总时长2秒 const steps = totalDuration ~/ 50; // 40次更新(50ms间隔) final stepValue = 1.0 / steps; diff --git a/local_plugins/azure_speech/LICENSE b/local_plugins/azure_speech/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/azure_speech/ios/Classes/.gitkeep b/local_plugins/azure_speech/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/azure_speech/ios/azure_speech.podspec b/local_plugins/azure_speech/ios/azure_speech.podspec new file mode 100644 index 000000000..bc179f0c1 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint azure_speech.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'azure_speech' + s.version = '0.0.1' + s.summary = 'Azure Speech plugin' + s.description = <<-DESC +A Flutter plugin for Azure Speech services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.dependency 'MicrosoftCognitiveServicesSpeech-iOS' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/chat_storage/LICENSE b/local_plugins/chat_storage/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/chat_storage_sqlite/README.md b/local_plugins/chat_storage/README.md similarity index 82% rename from local_plugins/chat_storage_sqlite/README.md rename to local_plugins/chat_storage/README.md index 8db279af0..223dfa095 100644 --- a/local_plugins/chat_storage_sqlite/README.md +++ b/local_plugins/chat_storage/README.md @@ -14,10 +14,10 @@ SQLite存储插件,用于存储聊天记录并支持分页查询。 ### 保存聊天记录 ```dart -import 'package:chat_storage_sqlite/chat_storage_sqlite.dart'; +import 'package:chat_storage/chat_storage.dart'; // 保存一条聊天记录 -final result = await ChatStorageSqlite.saveMessage( +final result = await ChatStorage.saveMessage( sessionId: 'session_123', message: '你好,这是一条测试消息', sender: 'user', @@ -35,7 +35,7 @@ if (result['success']) { ```dart // 获取指定会话的聊天记录,支持分页 -final pagedResult = await ChatStorageSqlite.getMessages( +final pagedResult = await ChatStorage.getMessages( sessionId: 'session_123', page: 1, // 当前页码,从1开始 pageSize: 20, // 每页记录数 @@ -60,17 +60,17 @@ for (var message in pagedResult.messages) { ```dart // 方式1:删除指定ID的消息 -final deleteResult1 = await ChatStorageSqlite.deleteMessages( +final deleteResult1 = await ChatStorage.deleteMessages( messageIds: [1, 2, 3], ); // 方式2:删除整个会话的所有消息 -final deleteResult2 = await ChatStorageSqlite.deleteMessages( +final deleteResult2 = await ChatStorage.deleteMessages( sessionId: 'session_123', ); // 方式3:清空整个数据库 -final clearResult = await ChatStorageSqlite.clearDatabase(); +final clearResult = await ChatStorage.clearDatabase(); ``` ## 注意事项 diff --git a/local_plugins/chat_storage_sqlite/android/build.gradle.kts b/local_plugins/chat_storage/android/build.gradle.kts similarity index 96% rename from local_plugins/chat_storage_sqlite/android/build.gradle.kts rename to local_plugins/chat_storage/android/build.gradle.kts index 78e1b31ea..458280df7 100644 --- a/local_plugins/chat_storage_sqlite/android/build.gradle.kts +++ b/local_plugins/chat_storage/android/build.gradle.kts @@ -8,7 +8,7 @@ plugins { android { // 命名空间,对应你插件的包名(需与代码内包名保持一致) - namespace = "com.yunqiinnovation.chat_storage_sqlite" + namespace = "com.yunqiinnovation.chat_storage" // 目标 SDK 版本 compileSdk = 33 diff --git a/local_plugins/chat_storage/android/settings.gradle.kts b/local_plugins/chat_storage/android/settings.gradle.kts new file mode 100644 index 000000000..2a25170b4 --- /dev/null +++ b/local_plugins/chat_storage/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "chat_storage" \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml b/local_plugins/chat_storage/android/src/main/AndroidManifest.xml similarity index 82% rename from local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml rename to local_plugins/chat_storage/android/src/main/AndroidManifest.xml index b7fb5f78b..2ab64f02a 100644 --- a/local_plugins/chat_storage_sqlite/android/src/main/AndroidManifest.xml +++ b/local_plugins/chat_storage/android/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ + package="com.yunqiinnovation.chat_storage"> diff --git a/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt similarity index 98% rename from local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt rename to local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt index d302c4d7d..9e4711531 100644 --- a/local_plugins/chat_storage_sqlite/android/src/main/kotlin/com/yunqiinnovation/chat_storage_sqlite/ChatStorageSqlitePlugin.kt +++ b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt @@ -1,4 +1,4 @@ -package com.yunqiinnovation.chat_storage_sqlite +package com.yunqiinnovation.chat_storage import android.content.Context import android.database.Cursor @@ -65,14 +65,14 @@ private class ChatDatabaseHelper(context: Context) : } /** ChatStorageSqlitePlugin */ -class ChatStorageSqlitePlugin: FlutterPlugin, MethodCallHandler { +class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { private lateinit var channel : MethodChannel private lateinit var context: Context private lateinit var dbHelper: ChatDatabaseHelper override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { context = flutterPluginBinding.applicationContext - channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage_sqlite") + channel = MethodChannel(flutterPluginBinding.binaryMessenger, "chat_storage") channel.setMethodCallHandler(this) dbHelper = ChatDatabaseHelper(context) } diff --git a/local_plugins/chat_storage/ios/Classes/.gitkeep b/local_plugins/chat_storage/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/chat_storage/ios/chat_storage.podspec b/local_plugins/chat_storage/ios/chat_storage.podspec new file mode 100644 index 000000000..b7a2c39b8 --- /dev/null +++ b/local_plugins/chat_storage/ios/chat_storage.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint chat_storage.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'chat_storage' + s.version = '0.0.1' + s.summary = 'Chat Storage plugin' + s.description = <<-DESC +A Flutter plugin for chat storage services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart b/local_plugins/chat_storage/lib/chat_storage.dart similarity index 98% rename from local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart rename to local_plugins/chat_storage/lib/chat_storage.dart index f41f473a7..2119dd84b 100644 --- a/local_plugins/chat_storage_sqlite/lib/chat_storage_sqlite.dart +++ b/local_plugins/chat_storage/lib/chat_storage.dart @@ -62,8 +62,8 @@ class PagedResult { } /// SQLite聊天记录存储插件 -class ChatStorageSqlite { - static const MethodChannel _channel = MethodChannel('chat_storage_sqlite'); +class ChatStorage { + static const MethodChannel _channel = MethodChannel('chat_storage'); /// 保存一条聊天记录 static Future> saveMessage({ @@ -77,7 +77,6 @@ class ChatStorageSqlite { 'sessionId': sessionId, 'message': message, 'sender': sender, - 'metadata': metadata, }); return Map.from(result); } catch (e) { diff --git a/local_plugins/chat_storage_sqlite/pubspec.yaml b/local_plugins/chat_storage/pubspec.yaml similarity index 61% rename from local_plugins/chat_storage_sqlite/pubspec.yaml rename to local_plugins/chat_storage/pubspec.yaml index a4d6e1bf1..0a68a4a25 100644 --- a/local_plugins/chat_storage_sqlite/pubspec.yaml +++ b/local_plugins/chat_storage/pubspec.yaml @@ -1,7 +1,7 @@ -name: chat_storage_sqlite -description: SQLite存储插件,用于存储聊天记录并支持分页查询 +name: chat_storage +description: A Flutter plugin for storing chat messages using SQLite. version: 0.0.1 -homepage: +# homepage: environment: sdk: ">=2.17.0 <3.0.0" @@ -24,7 +24,7 @@ flutter: plugin: platforms: android: - package: com.yunqiinnovation.chat_storage_sqlite - pluginClass: ChatStorageSqlitePlugin + package: com.yunqiinnovation.chat_storage + pluginClass: ChatStoragePlugin ios: - pluginClass: ChatStorageSqlitePlugin \ No newline at end of file + pluginClass: ChatStoragePlugin \ No newline at end of file diff --git a/local_plugins/chat_storage_sqlite/android/settings.gradle.kts b/local_plugins/chat_storage_sqlite/android/settings.gradle.kts deleted file mode 100644 index 613be470e..000000000 --- a/local_plugins/chat_storage_sqlite/android/settings.gradle.kts +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = "chat_storage_sqlite" \ No newline at end of file diff --git a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt index 0ba59a6e6..dec530b53 100644 --- a/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt +++ b/local_plugins/jieli/android/src/main/kotlin/com/yunqiinnovation/jieli/BleService.kt @@ -45,7 +45,7 @@ interface BleCallback { */ class BleService(private val context: Context) { companion object { - private const val TAG = "JieliBleCoreService" + private const val TAG = "JieliBleService" // 服务和特征UUID private const val AUDIO_SERVICE_UUID = "0000A100-0000-1000-8000-00805F9B34FB" diff --git a/local_plugins/open_ai_service/LICENSE b/local_plugins/open_ai_service/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt index 9e67d15f8..9ca318840 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIService.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.withContext import okhttp3.* import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.RequestBody.Companion.toRequestBody +import okio.Buffer import org.json.JSONArray import org.json.JSONObject import java.io.IOException @@ -389,8 +390,8 @@ class OpenAIService(private val context: Context? = null) { .build() // 输出用于测试的curl命令 - // val curlCommand = buildCurlCommand(request, requestBody.toString()) - // Log.d(TAG, "curl command: $curlCommand") + val curlCommand = buildCurlCommand(request, requestBody.toString()) + Log.d(TAG, "curl command: $curlCommand") client.newCall(request).enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { @@ -406,81 +407,95 @@ class OpenAIService(private val context: Context? = null) { val responseBody = response.body ?: return val source = responseBody.source() + val buffer = Buffer() try { - // 预取数据到缓冲区 - source.request(Long.MAX_VALUE) - val bufferedSource = source.buffer - // 用于存储函数调用的各个部分 val finalToolCalls = mutableMapOf() + val lineBuffer = StringBuilder() - while (!bufferedSource.exhausted()) { - val line = bufferedSource.readUtf8Line()?.trim() ?: continue - if (line.isEmpty()) continue - if (line.startsWith("data:")) { - val data = line.substring(5).trim() - - // 处理[DONE]消息 - if (data == "[DONE]" || data == "[\"DONE\"]") { - processToolCalls(finalToolCalls, callback, messages) - callback.onComplete() - break - } + // 逐块读取数据,而不是一次性预取 + val BUFFER_SIZE = 8192L // 8KB的块大小 + + while (!Thread.currentThread().isInterrupted) { + // 读取一小块数据 + val bytesRead = source.read(buffer, BUFFER_SIZE) + if (bytesRead == -1L) break // 流结束 + + // 处理接收到的数据 + while (true) { + val line = buffer.readUtf8Line() ?: break + if (line.trim().isEmpty()) continue - try { - val jsonData = JSONObject(data) + if (line.startsWith("data:")) { + val data = line.substring(5).trim() + Log.d("OpenAIService", "$data") - // 处理消息内容 - if (jsonData.has("choices")) { - val choices = jsonData.getJSONArray("choices") - if (choices.length() > 0) { - val choice = choices.getJSONObject(0) - - if (choice.has("delta")) { - val delta = choice.getJSONObject("delta") - - // 处理普通文本内容 - if (delta.has("content")) { - val content = delta.getString("content") - callback.onToken(content) - } + // 处理[DONE]消息 + if (data == "[DONE]" || data == "[\"DONE\"]") { + Log.d(TAG, "收到[DONE]消息") + val hasToolCalls = processToolCalls(finalToolCalls, callback, messages) + // 只有在没有工具调用时才认为对话真正完成 + if (!hasToolCalls) { + callback.onComplete() + } + return + } + + try { + val jsonData = JSONObject(data) + + // 处理消息内容 + if (jsonData.has("choices")) { + val choices = jsonData.getJSONArray("choices") + if (choices.length() > 0) { + val choice = choices.getJSONObject(0) - // 处理工具调用(函数调用) - if (delta.has("tool_calls")) { - val toolCalls = delta.getJSONArray("tool_calls") - for (i in 0 until toolCalls.length()) { - val toolCall = toolCalls.getJSONObject(i) - val index = toolCall.getInt("index") - - // 创建或获取现有的工具调用信息 - val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } - - // 更新ID - if (toolCall.has("id")) { - toolCallInfo.id = toolCall.getString("id") - } - - // 更新函数信息 - if (toolCall.has("function")) { - val function = toolCall.getJSONObject("function") + if (choice.has("delta")) { + val delta = choice.getJSONObject("delta") + + // 处理普通文本内容 + if (delta.has("content")) { + val content = delta.getString("content") + callback.onToken(content) + } + + // 处理工具调用(函数调用) + if (delta.has("tool_calls")) { + val toolCalls = delta.getJSONArray("tool_calls") + for (i in 0 until toolCalls.length()) { + val toolCall = toolCalls.getJSONObject(i) + val index = toolCall.getInt("index") + + // 创建或获取现有的工具调用信息 + val toolCallInfo = finalToolCalls.getOrPut(index) { ToolCallInfo() } - if (function.has("name")) { - toolCallInfo.name = function.getString("name") + // 更新ID + if (toolCall.has("id")) { + toolCallInfo.id = toolCall.getString("id") } - if (function.has("arguments")) { - toolCallInfo.arguments += function.getString("arguments") + // 更新函数信息 + if (toolCall.has("function")) { + val function = toolCall.getJSONObject("function") + + if (function.has("name")) { + toolCallInfo.name = function.getString("name") + } + + if (function.has("arguments")) { + toolCallInfo.arguments += function.getString("arguments") + } } } } } } } + } catch (e: Exception) { + // 忽略解析错误 + Log.e(TAG, "解析JSON出错: ${e.message}") } - } catch (e: Exception) { - // 忽略解析错误 - Log.e(TAG, "解析JSON出错: ${e.message}") } } } @@ -548,11 +563,11 @@ class OpenAIService(private val context: Context? = null) { /** * 处理工具调用结果 */ - private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null) { - if (toolCalls.isEmpty()) return + private fun processToolCalls(toolCalls: Map, callback: StreamCallback, messages: JSONArray? = null): Boolean { + if (toolCalls.isEmpty()) return false // 只处理第一个工具调用 - val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return + val firstToolCall = toolCalls.entries.firstOrNull()?.value ?: return false if (firstToolCall.isValid()) { // 创建函数调用JSON对象 @@ -572,7 +587,9 @@ class OpenAIService(private val context: Context? = null) { } } } + return true } + return false } /** diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt index 1e1bb9446..091af559c 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/OpenAIServicePlugin.kt @@ -1,6 +1,8 @@ package com.yunqiinnovation.open_ai_service import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -43,8 +45,11 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { /// 事件接收器(用于流式输出) private var eventSink: EventSink? = null - /// 执行器(用于后台线程) + /// 执行器(用于真正需要在后台线程处理的任务) private val executor = Executors.newSingleThreadExecutor() + + /// 主线程Handler + private val mainHandler = Handler(Looper.getMainLooper()) override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { // 保存上下文 @@ -98,17 +103,17 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { messages.put(JSONObject(message)) } - // 在后台线程执行请求 + // 对于需要在后台执行的同步阻塞操作,仍然使用executor executor.execute { try { val response = openAIService.sendMessage(messages) // 在主线程返回结果 - android.os.Handler(android.os.Looper.getMainLooper()).post { + mainHandler.post { result.success(response) } } catch (e: Exception) { // 在主线程返回错误 - android.os.Handler(android.os.Looper.getMainLooper()).post { + mainHandler.post { result.error("OPENAI_ERROR", e.message, null) } } @@ -130,53 +135,47 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { messages.put(JSONObject(message)) } - // 在后台线程执行请求 - executor.execute { - try { - openAIService.sendMessageStream( - messages = messages, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 发送token事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "token", "content" to token)) - } + // 不再在后台线程包装OkHttp的异步调用 + try { + openAIService.sendMessageStream( + messages = messages, + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + // 发送token事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "token", "content" to token)) } - - override fun onComplete() { - // 发送完成事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "complete")) - } + } + + override fun onComplete() { + // 发送完成事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "complete")) } - - override fun onError(e: Exception) { - // 发送错误事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "error", "content" to e.message)) - } + } + + override fun onError(e: Exception) { + // 发送错误事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "error", "content" to e.message)) } - - override fun onFunctionCall(functionCall: JSONObject) { - // 发送函数调用事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - val functionCallMap = functionCall.toMap() - eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) - } + } + + override fun onFunctionCall(functionCall: JSONObject) { + // 发送函数调用事件 + mainHandler.post { + val functionCallMap = functionCall.toMap() + eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) } } - ) - - // 请求已开始 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.success(true) } - } catch (e: Exception) { - // 在主线程返回错误 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.error("OPENAI_ERROR", e.message, null) - } - } + ) + + // 请求已开始,直接返回结果 + result.success(true) + } catch (e: Exception) { + // 直接在当前线程处理错误 + result.error("OPENAI_ERROR", e.message, null) } } @@ -200,55 +199,49 @@ class OpenAIServicePlugin : FlutterPlugin, MethodCallHandler, StreamHandler { // 转换函数调用 val functionCall = JSONObject(functionCallRaw) - // 在后台线程执行请求 - executor.execute { - try { - openAIService.sendFunctionCallResult( - messages = messages, - functionCall = functionCall, - functionResult = functionResult, - callback = object : OpenAIService.StreamCallback { - override fun onToken(token: String) { - // 发送token事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "token", "content" to token)) - } + // 不再在后台线程包装OkHttp的异步调用 + try { + openAIService.sendFunctionCallResult( + messages = messages, + functionCall = functionCall, + functionResult = functionResult, + callback = object : OpenAIService.StreamCallback { + override fun onToken(token: String) { + // 发送token事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "token", "content" to token)) } - - override fun onComplete() { - // 发送完成事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "complete")) - } + } + + override fun onComplete() { + // 发送完成事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "complete")) } - - override fun onError(e: Exception) { - // 发送错误事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - eventSink?.success(mapOf("type" to "error", "content" to e.message)) - } + } + + override fun onError(e: Exception) { + // 发送错误事件 + mainHandler.post { + eventSink?.success(mapOf("type" to "error", "content" to e.message)) } - - override fun onFunctionCall(nestedFunctionCall: JSONObject) { - // 发送函数调用事件 - android.os.Handler(android.os.Looper.getMainLooper()).post { - val functionCallMap = nestedFunctionCall.toMap() - eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) - } + } + + override fun onFunctionCall(nestedFunctionCall: JSONObject) { + // 发送函数调用事件 + mainHandler.post { + val functionCallMap = nestedFunctionCall.toMap() + eventSink?.success(mapOf("type" to "functionCall", "content" to functionCallMap)) } } - ) - - // 请求已开始 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.success(true) } - } catch (e: Exception) { - // 在主线程返回错误 - android.os.Handler(android.os.Looper.getMainLooper()).post { - result.error("OPENAI_ERROR", e.message, null) - } - } + ) + + // 请求已开始,直接返回结果 + result.success(true) + } catch (e: Exception) { + // 直接在当前线程处理错误 + result.error("OPENAI_ERROR", e.message, null) } } diff --git a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt index f2e7cbcdb..875895d65 100644 --- a/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt +++ b/local_plugins/open_ai_service/android/src/main/kotlin/com/yunqiinnovation/open_ai_service/SystemFunctionHandler.kt @@ -78,7 +78,7 @@ class SystemFunctionHandler(private val context: Context) { private fun registerTranslationModeFunction(client: MCPClient) { client.registerLocalFunction( "enter_translation_mode", - "进入实时翻译模式", + "用户请求进入实时翻译模式时,启动实时翻译功能", JSONObject(""" { "type": "object", @@ -167,7 +167,7 @@ class SystemFunctionHandler(private val context: Context) { private fun registerOpenAppFunction(client: MCPClient) { client.registerLocalFunction( "open_app", - "打开应用", + "当用户请求打开特定应用时,启动相应应用", JSONObject(""" { "type": "object", diff --git a/local_plugins/open_ai_service/ios/Classes/.gitkeep b/local_plugins/open_ai_service/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift index 671affd73..5df99e126 100644 --- a/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift +++ b/local_plugins/open_ai_service/ios/Classes/OpenAIService.swift @@ -237,30 +237,47 @@ public class OpenAIService { } /// 发送消息(流式输出) - public func sendMessageStream(messages: [[String: Any]], systemPrompt: String, callback: @escaping StreamCallback) { + public func sendMessageStream( + messages: [[String: Any]], + systemPrompt: String, + callback: @escaping StreamCallback + ) { guard isInitialized, !apiKey.isEmpty else { callback.onError(OpenAIError("OpenAI服务未初始化")) return } - // 构建完整消息,添加系统提示 - var fullMessages: [[String: Any]] = [ - ["role": "system", "content": systemPrompt] - ] - fullMessages.append(contentsOf: messages) - // 构建请求体 - var requestDict: [String: Any] = [ + var requestBody: [String: Any] = [ "model": model, - "messages": fullMessages, "temperature": 0.7, "max_tokens": 2000, "stream": true ] - // 如果有注册的函数,添加到请求中 - if !registeredFunctions.isEmpty { + // 构建完整消息数组,添加系统提示 + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + fullMessages.append(contentsOf: messages) + requestBody["messages"] = fullMessages + + // 添加工具列表 + if let toolMaps = mcpClient?.getToolMaps(), !toolMaps.isEmpty { + var tools: [[String: Any]] = [] + + for toolMap in toolMaps { + if let tool = toolMap as? [String: Any] { + tools.append(tool) + } + } + + if !tools.isEmpty { + requestBody["tools"] = tools + } + } else if !registeredFunctions.isEmpty { var tools: [[String: Any]] = [] + for function in registeredFunctions { let tool: [String: Any] = [ "type": "function", @@ -268,11 +285,14 @@ public class OpenAIService { ] tools.append(tool) } - requestDict["tools"] = tools + + if !tools.isEmpty { + requestBody["tools"] = tools + } } - // 将请求数据转换为JSON数据 - guard let jsonData = try? JSONSerialization.data(withJSONObject: requestDict) else { + // 转换为JSON数据 + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody) else { callback.onError(OpenAIError("无法序列化请求数据")) return } @@ -290,102 +310,10 @@ public class OpenAIService { request.addValue("text/event-stream", forHTTPHeaderField: "Accept") request.httpBody = jsonData - // 用于存储函数调用的各个部分 - var finalToolCalls: [Int: ToolCallInfo] = [:] - - // 创建数据任务 - let task = session.dataTask(with: request) { data, response, error in - if let error = error { - callback.onError(OpenAIError("请求失败: \(error.localizedDescription)")) - return - } - - guard let httpResponse = response as? HTTPURLResponse else { - callback.onError(OpenAIError("无效的HTTP响应")) - return - } - - guard httpResponse.statusCode == 200 else { - callback.onError(OpenAIError("API调用失败: \(httpResponse.statusCode)")) - return - } - - guard let data = data else { - callback.onError(OpenAIError("响应数据为空")) - return - } - - // 处理SSE数据流 - if let text = String(data: data, encoding: .utf8) { - let lines = text.components(separatedBy: "\n") - - for line in lines { - if line.isEmpty { continue } - - if line.hasPrefix("data: ") { - let dataContent = line.dropFirst(6) - - // 处理[DONE]消息 - if dataContent == "[DONE]" { - self.processToolCalls(finalToolCalls, callback: callback) - callback.onComplete() - break - } - - // 解析JSON数据 - do { - if let data = dataContent.data(using: .utf8), - let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - - // 处理消息内容 - if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty, - let choice = choices.first { - - if let delta = choice["delta"] as? [String: Any] { - // 处理普通文本内容 - if let content = delta["content"] as? String { - callback.onToken(content) - } - - // 处理工具调用(函数调用) - if let toolCalls = delta["tool_calls"] as? [[String: Any]] { - for toolCall in toolCalls { - if let index = toolCall["index"] as? Int { - // 创建或获取现有的工具调用信息 - let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo() - - // 更新ID - if let id = toolCall["id"] as? String { - toolCallInfo.id = id - } - - // 更新函数信息 - if let function = toolCall["function"] as? [String: Any] { - if let name = function["name"] as? String { - toolCallInfo.name = name - } - - if let arguments = function["arguments"] as? String { - toolCallInfo.arguments += arguments - } - } - - finalToolCalls[index] = toolCallInfo - } - } - } - } - } - } - } catch { - NSLog("解析JSON出错: \(error.localizedDescription)") - // 忽略解析错误,继续处理其他行 - } - } - } - } - } - + // 创建流式会话任务 + let delegate = SSEStreamDelegate(callback: callback) + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let task = session.dataTask(with: request) task.resume() } @@ -447,12 +375,147 @@ public class OpenAIService { } } + /// SSE流委托实现 + private class SSEStreamDelegate: NSObject, URLSessionDataDelegate { + let callback: StreamCallback + private var buffer = Data() + private var finalToolCalls: [Int: ToolCallInfo] = [:] + + init(callback: @escaping StreamCallback) { + self.callback = callback + super.init() + } + + // 接收数据流 + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + buffer.append(data) + + // 处理可能包含多行的数据 + processBuffer() + } + + // 处理缓冲区数据 + private func processBuffer() { + // 按行分割 + while let newlineIndex = buffer.firstIndex(of: 10) { // 10是换行符的ASCII码 + let lineData = buffer.prefix(upTo: newlineIndex) + buffer.removeSubrange(0...newlineIndex) // 移除已处理的行,包括换行符 + + // 解析行数据 + if let line = String(data: lineData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) { + processLine(line) + } + } + } + + // 处理单行数据 + private func processLine(_ line: String) { + guard !line.isEmpty else { return } + + if line.hasPrefix("data: ") { + let dataContent = line.dropFirst(6) + + // 处理[DONE]消息 + if dataContent == "[DONE]" { + let hasToolCalls = self.processToolCalls() + // 只有在没有工具调用时才认为对话真正完成 + if !hasToolCalls { + callback.onComplete() + } + return + } + + // 解析JSON数据 + do { + if let data = dataContent.data(using: .utf8), + let jsonData = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + + // 处理消息内容 + if let choices = jsonData["choices"] as? [[String: Any]], !choices.isEmpty, + let choice = choices.first { + + if let delta = choice["delta"] as? [String: Any] { + // 处理普通文本内容 + if let content = delta["content"] as? String { + callback.onToken(content) + } + + // 处理工具调用(函数调用) + if let toolCalls = delta["tool_calls"] as? [[String: Any]] { + for toolCall in toolCalls { + if let index = toolCall["index"] as? Int { + // 创建或获取现有的工具调用信息 + let toolCallInfo = finalToolCalls[index] ?? ToolCallInfo() + + // 更新ID + if let id = toolCall["id"] as? String { + toolCallInfo.id = id + } + + // 更新函数信息 + if let function = toolCall["function"] as? [String: Any] { + if let name = function["name"] as? String { + toolCallInfo.name = name + } + + if let arguments = function["arguments"] as? String { + toolCallInfo.arguments += arguments + } + } + + finalToolCalls[index] = toolCallInfo + } + } + } + } + } + } + } catch { + NSLog("解析JSON出错: \(error.localizedDescription)") + // 忽略解析错误,继续处理其他行 + } + } + } + + // 处理工具调用 + private func processToolCalls() -> Bool { + if finalToolCalls.isEmpty { return false } + + // 只处理第一个工具调用 + guard let firstToolCall = finalToolCalls.values.first, firstToolCall.isValid else { return false } + + // 创建函数调用字典 + let functionCall: [String: Any] = [ + "name": firstToolCall.name, + "arguments": firstToolCall.arguments, + "id": firstToolCall.id + ] + + // 回调 + callback.onFunctionCall(functionCall) + return true + } + + // 处理完成 + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + callback.onError(OpenAIError("请求失败: \(error.localizedDescription)")) + } + } + } + + /// 流式输出回调协议 + public typealias StreamCallback = (onToken: (String) -> Void, + onComplete: () -> Void, + onError: (Error) -> Void, + onFunctionCall: ([String: Any]) -> Void) + /// 处理工具调用(函数调用)并回调 - private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) { - if toolCalls.isEmpty { return } + private func processToolCalls(_ toolCalls: [Int: ToolCallInfo], callback: StreamCallback) -> Bool { + if toolCalls.isEmpty { return false } // 只处理第一个工具调用 - guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return } + guard let firstToolCall = toolCalls.values.first, firstToolCall.isValid else { return false } // 创建函数调用字典 let functionCall: [String: Any] = [ @@ -463,11 +526,6 @@ public class OpenAIService { // 回调 callback.onFunctionCall(functionCall) + return true } - - /// 流式输出回调协议 - public typealias StreamCallback = (onToken: (String) -> Void, - onComplete: () -> Void, - onError: (Error) -> Void, - onFunctionCall: ([String: Any]) -> Void) } \ No newline at end of file diff --git a/local_plugins/open_ai_service/ios/open_ai_service.podspec b/local_plugins/open_ai_service/ios/open_ai_service.podspec new file mode 100644 index 000000000..d06d07fed --- /dev/null +++ b/local_plugins/open_ai_service/ios/open_ai_service.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint open_ai_service.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'open_ai_service' + s.version = '0.0.1' + s.summary = 'OpenAI Service plugin' + s.description = <<-DESC +A Flutter plugin for OpenAI services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/local_plugins/volcano_speech/LICENSE b/local_plugins/volcano_speech/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/volcano_speech/ios/Classes/.gitkeep b/local_plugins/volcano_speech/ios/Classes/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/volcano_speech/ios/volcano_speech.podspec b/local_plugins/volcano_speech/ios/volcano_speech.podspec new file mode 100644 index 000000000..9a70baed1 --- /dev/null +++ b/local_plugins/volcano_speech/ios/volcano_speech.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint volcano_speech.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'volcano_speech' + s.version = '0.0.1' + s.summary = 'Volcano Speech plugin' + s.description = <<-DESC +A Flutter plugin for Volcano Speech services. + DESC + s.homepage = 'http://example.com' + s.license = { :file => '../LICENSE' } + s.author = { 'Your Company' => 'email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index b2962bec9..b08d2746e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,8 +68,8 @@ dependencies: path: local_plugins/volcano_speech jieli: path: local_plugins/jieli - chat_storage_sqlite: - path: local_plugins/chat_storage_sqlite + chat_storage: + path: local_plugins/chat_storage dev_dependencies: diff --git a/test.json b/test.json index 473daebb6..2411527e2 100644 --- a/test.json +++ b/test.json @@ -1,57 +1,163 @@ -curl -v -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' -H 'Accept: text/event-stream' -d '{ - "model": "bot-20250405211523-l7c9r", - "messages": [ - { - "role": "system", - "content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" - }, +curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ +-H "Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6" \ +-H 'Content-Type: application/json' \ +-d '{ + "model": "bot-20250405211523-l7c9r", + "stream": true, + "stream_options": {"include_usage": true}, + "messages": [ { "role": "system", - "content": " 你是一个智能语音助手,能够简洁明了地回答用户的问题。\n时刻关心用户的情绪和需求,主动提供鼓励和温暖。\n\n语言风格活泼、亲切,能够幽默地互动,陪伴用户,缓解压力,增添生活乐趣。\n\n请始终以用户为中心,保持回应的高效性、准确性和温暖体贴,成为用户真正的灵魂伴侣。\n \n 当用户说\"退出\"、\"再见\"、\"结束对话\"等类似意图时,你应该使用exit_interaction函数来结束对话,\n 并在结束前说一句友好的告别语,例如\"再见,有需要随时找我\"。" + "content": "You are a helpful assistant." }, { "role": "user", - "content": "退下吧。" - }, - { - "role": "assistant", - "content": "", - "tool_calls": [ - { - "id": "call_8k680azmfc4thqrrnwpwqxah", - "type": "function", - "function": { - "name": "exit_interaction", - "arguments": " {}" - } - } - ] - }, - { - "role": "tool", - "content": "{\"result\": \"已退出语音交互\"}", - "tool_call_id": "call_8k680azmfc4thqrrnwpwqxah" + "content": "Hello!" } - ], - "temperature": 0.7, - "max_tokens": 2000, - "stream": true, - "tools": [ - { - "type": "function", - "function": { - "name": "exit_interaction", - "description": "退出当前语音交互", - "parameters": { - "type": "object", - "properties": {}, - "required": [] - } + ] +}' + + + + +curl 'https://ark.cn-beijing.volces.com/api/v3/bots/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer 168deb3d-fd0c-4912-b9f1-aaee5c6743e6' \ +-d '{ + "model": "bot-20250405211523-l7c9r", + "stream": true, + "messages": [ + { + "role": "assistant", + "content": "你好呀!今天想和我聊些什么呢😄 是生活趣事、学习心得,还是其他好玩的话题?" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "assistant", + "content": "你好呀!今天过得怎么样?有什么需要我帮忙的吗?或者只是想聊聊天?😊" + }, + { + "role": "assistant", + "content": "明白啦!有什么需要我帮忙的吗?无论是查资料、聊天解闷,还是帮你处理手机上的任务,我随时待命哦~ 😊" + }, + { + "role": "assistant", + "content": "好的!随时准备陪你测试,有什么需要尽管说哦~ 😊 你希望测试什么功能呢?" + }, + { + "role": "assistant", + "content": "哎呀,听起来你有点小困扰呢!可能是因为网络波动、系统更新或者我偶尔“打瞌睡”了(开玩笑啦~)。不过别担心,我会努力保持最佳状态陪伴你!如果有什么具体问题,随时告诉我,我一定帮你解决得妥妥的!😊\n\n(或者你想测试什么功能?我随时待命哦!)" + }, + { + "role": "assistant", + "content": "嘿嘿,我是你的贴心语音助理小语呀!随时为你提供帮助、陪你聊天、解答问题,还能给你加油打气哦~有什么需要尽管告诉我吧! 😊" + }, + { + "role": "assistant", + "content": "我是小语呀,那个随时准备和你畅聊、给你排忧解难、陪你度过各种时光的手机全能语音助理😜 怎么啦,是不是刚才没记住呀🧐" + }, + { + "role": "assistant", + "content": "" + }, + { + "role": "user", + "content": "你好呀,你是谁呀?" + } + ], + "temperature": 0.7, + "max_tokens": 2000, + "stream": true, + "tools": [ + { + "type": "function", + "function": { + "name": "exit_interaction", + "description": "退出当前语音交互", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "enter_translation_mode", + "description": "用户请求进入实时翻译模式时,启动实时翻译功能", + "parameters": { + "type": "object", + "properties": {}, + "required": [] + } + } + }, + { + "type": "function", + "function": { + "name": "send_text_message", + "description": "发送短信", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + }, + "message": { + "type": "string", + "description": "短信内容" } + }, + "required": [ + "contact", + "message" + ] } - ] -}' 'https: //ark.cn-beijing.volces.com/api/v3/bots/chat/completions' -{ - "code": 0, - "message": "Error during WebSocket handshake: Unexpected response code: 400", - "request_log":"{\"ab_test\":{},\"base\":{\"has_upload\":false,\"is_pending\":true,\"load_flags\":18,\"load_state\":0,\"method\":\"GET\",\"net_error\":-320,\"network_isolation_key\":\"wss://openspeech.bytedance.com wss://openspeech.bytedance.com\",\"origin_url\":\"wss://openspeech.bytedance.com/api/v3/sauc/bigmodel\",\"redirect_times\":0,\"status\":\"FAILED\",\"traffic_annotation\":77012883},\"dns\":{\"address_list\":[\"163.181.50.229:443\",\"163.181.50.223:443\",\"163.181.50.221:443\",\"163.181.50.224:443\",\"163.181.50.226:443\",\"163.181.50.222:443\",\"163.181.50.228:443\",\"163.181.50.227:443\"],\"async_batch_httpdns\":0,\"cache_source\":6,\"cache_stale_reason\":0,\"from_stale_cache\":0,\"hijacked_error\":0,\"is_auth\":false,\"nameservers\":[],\"net_type\":0,\"preload_batch_httpdns\":0,\"queried_with_cip\":\"\",\"race_dns_stale_cache\":0,\"skip_prefer_ip_error\":0,\"source\":2},\"event\":{\"engine_startup\":1744019237.421243,\"network_change\":0.0},\"header\":{\"X-Tt-Logid\":\"2025040717474815E3294F926C737834FA\",\"server-timing\":\"inner; dur=5, cdn-cache;desc=MISS,edge;dur=653,origin;dur=14\",\"x-tt-trace-host\":\"012d6b6cae97bb056dd2540a62f7f6e52ccfb433febe931be5ddf1ac674ca77c57b7dee570790477696b7b5c17bbdd77bf841100de6d411ef8ccc26681b9799e4aee97897e0681b3a8b304853d1848fcbd9fec458fdd375cddc4a7873cdbcef3a0\",\"x-tt-trace-id\":\"00-0fa6216304269466036ad7b0703104d2-0fa6216304269466-01\",\"x-tt-trace-tag\":\"id=03;cdn-cache=miss;type=dyn\"},\"ifconfig\":{\"ipv4_reachable\":true,\"ipv6_reachable\":false},\"multinetwork\":{},\"nqe\":{\"bs_sys_state\":false,\"downlink_throughput\":2658,\"effective_net_type\":5,\"group_rtt_estimate\":[],\"http_rtt\":116,\"pending_requests\":\"1\",\"real_net_type\":2,\"tcp_rtt\":46,\"total_requests\":\"6\"},\"other\":{\"core_ver\":\"87.0.4273.1\",\"gateway\":\"0.0.0.0\",\"is_background\":false,\"is_internal_retry\":false,\"is_main_process\":true,\"libcore\":\"cronet\",\"net_thread_priority\":0,\"quic_commit\":\"6ea2111b/2023-03-16\",\"resume_time\":\"-1\",\"retry_attempts\":0,\"ttnet_build_timestamp\":\"1689596445\",\"ttnet_version\":\"4.1.127.2-tob\",\"version\":\"534436af/2023-07-17\"},\"response\":{\"cached\":false,\"code\":400,\"connection_info\":1,\"network_accessed\":true,\"received_bytes\":0,\"sent_bytes\":0,\"via_proxy\":false},\"session\":{},\"socket\":{\"address_family\":1,\"cip\":\"\",\"connection_attemps\":[],\"happy_eyeballs_result\":2,\"remote\":\"163.181.50.229:443\",\"socket_reused\":false},\"socketpool\":{\"assign_type\":0,\"consume_time\":0,\"is_backup_job\":false,\"pending_reason\":0,\"pending_time\":0,\"reuse_type\":0,\"socket_idle_time\":0},\"ssl\":{\"cert_status\":0,\"connection_status\":6296321,\"handshake_type\":1,\"is_issued_by_known_root\":true,\"server_cert_common_name\":\"*.bytedance.com\"},\"store_idc\":{\"init\":1},\"timing\":{\"detailed_duration\":{\"before_stream\":0,\"body_recv\":-1,\"cdn-cache\":\"miss\",\"connect_delay\":0,\"create_stream\":98,\"dispatch\":0,\"dns\":0,\"edge\":653,\"header_recv\":-1,\"inner\":5,\"origin\":9,\"proxy\":0,\"rtt\":43,\"security_callback\":0,\"send\":0,\"ssl\":48,\"tcp\":49,\"ttfb\":710},\"request\":{\"create_stream_finish\":1744019267.633028,\"create_stream_start\":1744019267.534112,\"duration\":812,\"http_transaction_start\":1744019267.534081,\"request_sent_time\":1744019267.633497,\"response_recv_time\":1744019268.344466,\"start_time\":1744019267.533685}},\"tnc\":{\"data\":{\"canary\":\"\",\"cold_start\":1,\"config\":\"\",\"config_updated\":0,\"local_probe_version\":\"\",\"rules\":\"\",\"source\":0,\"update_time\":\"-1\"},\"probe\":{\"cmd\":\"0\",\"get_time\":\"-1\",\"send_time\":\"-1\",\"state\":1,\"version\":\"0\"},\"request\":{\"probe_version\":\"0\",\"source\":0,\"start_time\":\"-1\",\"state\":1}},\"t \ No newline at end of file + } + }, + { + "type": "function", + "function": { + "name": "make_phone_call", + "description": "拨打电话", + "parameters": { + "type": "object", + "properties": { + "contact": { + "type": "string", + "description": "联系人姓名或电话号码" + } + }, + "required": [ + "contact" + ] + } + } + }, + { + "type": "function", + "function": { + "name": "open_app", + "description": "当用户请求打开特定应用时,启动相应应用", + "parameters": { + "type": "object", + "properties": { + "app_name": { + "type": "string", + "description": "应用名称" + } + }, + "required": [ + "app_name" + ] + } + } + } + ] +}' +