From 4992fd03d95249222701827e2038bfad9501b19e Mon Sep 17 00:00:00 2001 From: wolfplus Date: Thu, 29 May 2025 11:29:11 +0100 Subject: [PATCH] add --- ios/Runner.xcodeproj/project.pbxproj | 37 ++ ios/Runner/Info.plist | 7 +- .../ios/Classes/AgentServiceImpl.swift | 1 - .../ble_service/ios/Classes/BleService.swift | 5 + .../ios/Classes/SwiftOpusAudioProcessor.swift | 19 +- .../chat_api/IOS_INTEGRATION_LEGACY.md | 84 ++++ local_plugins/chat_api/LICENSE | 0 local_plugins/chat_api/README.md | 239 ++++++++++++ local_plugins/chat_api/SPM_MIGRATION.md | 108 ++++++ local_plugins/chat_api/android/build.gradle | 64 +++ .../android/src/main/AndroidManifest.xml | 3 + .../yunqiinnovation/chat_api/ChatApiPlugin.kt | 71 ++++ local_plugins/chat_api/example/lib/main.dart | 266 +++++++++++++ local_plugins/chat_api/example/pubspec.yaml | 24 ++ local_plugins/chat_api/ios/chat_api.podspec | 44 +++ .../chat_api/ios/chat_api/Package.swift | 32 ++ .../Sources/chat_api/ChatApiPlugin.swift | 316 +++++++++++++++ .../Sources/chat_api/ChatService.swift | 363 ++++++++++++++++++ local_plugins/chat_api/lib/chat_api.dart | 358 +++++++++++++++++ local_plugins/chat_api/pubspec.yaml | 29 ++ 20 files changed, 2065 insertions(+), 5 deletions(-) create mode 100644 local_plugins/chat_api/IOS_INTEGRATION_LEGACY.md create mode 100644 local_plugins/chat_api/LICENSE create mode 100644 local_plugins/chat_api/README.md create mode 100644 local_plugins/chat_api/SPM_MIGRATION.md create mode 100644 local_plugins/chat_api/android/build.gradle create mode 100644 local_plugins/chat_api/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt create mode 100644 local_plugins/chat_api/example/lib/main.dart create mode 100644 local_plugins/chat_api/example/pubspec.yaml create mode 100644 local_plugins/chat_api/ios/chat_api.podspec create mode 100644 local_plugins/chat_api/ios/chat_api/Package.swift create mode 100644 local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift create mode 100644 local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatService.swift create mode 100644 local_plugins/chat_api/lib/chat_api.dart create mode 100644 local_plugins/chat_api/pubspec.yaml diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 5f9b20d6c..7570c46fb 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -70,6 +70,24 @@ D2A873E56435FC1342AE0A85 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; D7BFB5FE33B0270C30A1DFAA /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; E9725ECE141452716001447D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0AD9449B2DE6F89600557D21 /* Remove Bitcode from YbridOpus */ = { + isa = PBXFileReference; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Remove Bitcode from YbridOpus"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# 移除YbridOpus框架中的bitcode\necho \"检查并移除YbridOpus框架中的bitcode...\"\n\nFRAMEWORK_PATHS=(\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/ios-arm64_armv7/YbridOpus.framework/YbridOpus\"\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/ios-i386_x86_64-simulator/YbridOpus.framework/YbridOpus\"\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/ios-arm64_x86_64-maccatalyst/YbridOpus.framework/YbridOpus\"\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/macos-arm64_x86_64/YbridOpus.framework/YbridOpus\"\n)\n\nfor framework_path in \"${FRAMEWORK_PATHS[@]}\"; do\n if [ -f \"$framework_path\" ]; then\n if otool -l \"$framework_path\" | grep -q __LLVM; then\n echo \"移除bitcode: $framework_path\"\n # 直接覆盖,不创建备份\n xcrun bitcode_strip -r \"$framework_path\" -o \"$framework_path.tmp\"\n mv \"$framework_path.tmp\" \"$framework_path\"\n fi\n fi\ndone\n\n# 清理任何可能的备份文件\nfind \"${PODS_ROOT}\" -name \"*.backup\" -type f -delete 2>/dev/null || true\nfind \"${BUILT_PRODUCTS_DIR}\" -name \"*.backup\" -type f -delete 2>/dev/null || true\n\necho \"bitcode移除和清理完成!\""; + }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -206,6 +224,7 @@ 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 0AD9449B2DE6F89600557D21 /* Remove Bitcode from YbridOpus */, CC018A1832E51ED5B994765E /* [CP] Embed Pods Frameworks */, A196696487421EBA40281C38 /* [CP] Copy Pods Resources */, ); @@ -302,6 +321,24 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 0AD9449B2DE6F89600557D21 /* Remove Bitcode from YbridOpus */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Remove Bitcode from YbridOpus"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# 移除YbridOpus框架中的bitcode\necho \"检查并移除YbridOpus框架中的bitcode...\"\n\nFRAMEWORK_PATHS=(\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/ios-arm64_armv7/YbridOpus.framework/YbridOpus\"\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/ios-i386_x86_64-simulator/YbridOpus.framework/YbridOpus\"\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/ios-arm64_x86_64-maccatalyst/YbridOpus.framework/YbridOpus\"\n \"${PODS_ROOT}/YbridOpus/YbridOpus.xcframework/macos-arm64_x86_64/YbridOpus.framework/YbridOpus\"\n)\n\nfor framework_path in \"${FRAMEWORK_PATHS[@]}\"; do\n if [ -f \"$framework_path\" ]; then\n if otool -l \"$framework_path\" | grep -q __LLVM; then\n echo \"移除bitcode: $framework_path\"\n # 直接覆盖,不创建备份\n xcrun bitcode_strip -r \"$framework_path\" -o \"$framework_path.tmp\"\n mv \"$framework_path.tmp\" \"$framework_path\"\n fi\n fi\ndone\n\n# 清理任何可能的备份文件\nfind \"${PODS_ROOT}\" -name \"*.backup\" -type f -delete 2>/dev/null || true\nfind \"${BUILT_PRODUCTS_DIR}\" -name \"*.backup\" -type f -delete 2>/dev/null || true\n\necho \"bitcode移除和清理完成!\""; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 29a4c3d30..a9bea1fb5 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -107,13 +107,18 @@ 需要位置权限用于搜索附近的蓝牙设备 NSMicrophoneUsageDescription 需要麦克风权限用于语音识别和录音功能 + NSCalendarsUsageDescription + 需要日历权限用于安排和管理您的音频会议和录音任务 + NSPhotoLibraryUsageDescription + 需要相册权限用于保存录音文件和导入音频文件 + NSCameraUsageDescription + 需要相机权限用于扫描二维码和视频通话功能 UIApplicationSupportsIndirectInputEvents UIBackgroundModes bluetooth-central audio - processing UILaunchStoryboardName LaunchScreen diff --git a/local_plugins/agent_service/ios/Classes/AgentServiceImpl.swift b/local_plugins/agent_service/ios/Classes/AgentServiceImpl.swift index 387abfabb..5251191b0 100644 --- a/local_plugins/agent_service/ios/Classes/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/Classes/AgentServiceImpl.swift @@ -148,7 +148,6 @@ class AgentServiceImpl: NSObject { } else { initSystemPrompt() } - initSystemPrompt() if let azureSpeechKey = config["azureSpeechKey"] as? String { self.azureSpeechKey = azureSpeechKey } else { diff --git a/local_plugins/ble_service/ios/Classes/BleService.swift b/local_plugins/ble_service/ios/Classes/BleService.swift index 661947a71..0c7398b7c 100644 --- a/local_plugins/ble_service/ios/Classes/BleService.swift +++ b/local_plugins/ble_service/ios/Classes/BleService.swift @@ -529,6 +529,11 @@ public class BleService: NSObject { /// 处理收到的音频数据 private func processAudioData(_ data: Data) { + // 简单过滤:只处理合理大小的音频包 + guard data.count > 0 && data.count <= 1275 else { + return + } + // 使用Opus处理器处理音频数据 opusProcessor?.processAudioData(data) } diff --git a/local_plugins/ble_service/ios/Classes/SwiftOpusAudioProcessor.swift b/local_plugins/ble_service/ios/Classes/SwiftOpusAudioProcessor.swift index 0557d6517..a78378e70 100644 --- a/local_plugins/ble_service/ios/Classes/SwiftOpusAudioProcessor.swift +++ b/local_plugins/ble_service/ios/Classes/SwiftOpusAudioProcessor.swift @@ -85,16 +85,20 @@ class SwiftOpusAudioProcessor: NSObject { return } + // 过滤明显异常的数据包 + guard data.count <= 1275 else { + return + } + DispatchQueue.global(qos: .userInitiated).async { [weak self] in self?.decodeOpusPacket(data) } } - // 使用简单稳定的 Opus 解码方法 + // 简化的 Opus 解码方法,重点是容错处理 private func decodeOpusPacket(_ packet: Data) { guard let decoder = decoder else { return } - // 使用简单的直接解码方式 let maxSamples = Int(maxFrameSize) var pcmBuffer = [opus_int16](repeating: 0, count: maxSamples * Int(channels)) @@ -110,7 +114,10 @@ class SwiftOpusAudioProcessor: NSObject { ) } - guard decodedSamples > 0 else { + // 简单的错误处理:失败时重新初始化解码器 + if decodedSamples <= 0 { + print("Opus解码失败,重新初始化解码器") + resetDecoder() return } @@ -124,6 +131,12 @@ class SwiftOpusAudioProcessor: NSObject { } } + // 重置解码器的简单方法 + private func resetDecoder() { + destroyOpusDecoder() + _ = initializeOpusDecoder() + } + private func processPCMData(_ pcmData: [opus_int16]) { // 转换为 Data let data = Data(bytes: pcmData, count: pcmData.count * MemoryLayout.size) diff --git a/local_plugins/chat_api/IOS_INTEGRATION_LEGACY.md b/local_plugins/chat_api/IOS_INTEGRATION_LEGACY.md new file mode 100644 index 000000000..b4b1edad5 --- /dev/null +++ b/local_plugins/chat_api/IOS_INTEGRATION_LEGACY.md @@ -0,0 +1,84 @@ +# iOS 集成指南(旧版 CocoaPods 项目)- Chat API Plugin + +> **注意**:本指南仅适用于未启用 Swift Package Manager 的旧版 Flutter 项目。 +> +> 如果你的 Flutter 版本 >= 3.24,强烈建议启用 SPM 支持: +> ```bash +> flutter config --enable-swift-package-manager +> ``` +> 启用后,插件会自动处理所有依赖,无需手动配置。 + +## 手动集成步骤(仅限 CocoaPods 项目) + +由于 Flutter 插件中 CocoaPods 和 Swift Package Manager 的兼容性限制,需要手动在 Xcode 中添加 OpenAI 包依赖。 + +## 步骤说明 + +### 1. 运行 pod install + +首先在项目根目录运行: + +```bash +cd ios +pod install +``` + +### 2. 打开 Xcode 项目 + +打开生成的 workspace 文件: + +```bash +open Runner.xcworkspace +``` + +### 3. 添加 Swift Package 依赖 + +1. 在 Xcode 中,选择左侧的 `Runner` 项目(蓝色图标) +2. 在主编辑区域,选择 `Runner` target +3. 点击顶部的 `Package Dependencies` 标签 +4. 点击左下角的 `+` 按钮 +5. 在弹出的窗口中输入:`https://github.com/MacPaw/OpenAI.git` +6. 点击 `Add Package` +7. 在版本选择界面: + - 选择 `Up to Next Major Version` + - 起始版本填写:`0.4.0` +8. 点击 `Add Package` +9. 在包产品选择界面,确保 `OpenAI` 被选中,并且目标是 `Runner` +10. 点击 `Add Package` + +### 4. 验证集成 + +1. 在 Xcode 左侧导航栏中,展开 `Package Dependencies` +2. 确认能看到 `OpenAI` 包 +3. 尝试构建项目(Cmd + B)确保没有错误 + +### 5. 可能的问题 + +#### 问题:构建时提示找不到 OpenAI 模块 + +**解决方案**: +1. 清理构建缓存:`Product > Clean Build Folder` +2. 关闭 Xcode +3. 删除 `DerivedData`: + ```bash + rm -rf ~/Library/Developer/Xcode/DerivedData + ``` +4. 重新打开项目并构建 + +#### 问题:包依赖下载失败 + +**解决方案**: +1. 检查网络连接 +2. 在 Xcode 中:`File > Packages > Reset Package Caches` +3. 重新添加包依赖 + +## 注意事项 + +- 每次运行 `pod install` 后,包依赖配置会保留 +- 如果切换了 Xcode 版本,可能需要重新解析包依赖 +- 建议将 `.xcworkspace` 文件加入版本控制,但排除 `xcuserdata` 目录 + +## 相关链接 + +- [MacPaw OpenAI Swift 库](https://github.com/MacPaw/OpenAI) +- [Swift Package Manager 文档](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app) \ No newline at end of file diff --git a/local_plugins/chat_api/LICENSE b/local_plugins/chat_api/LICENSE new file mode 100644 index 000000000..e69de29bb diff --git a/local_plugins/chat_api/README.md b/local_plugins/chat_api/README.md new file mode 100644 index 000000000..f26fffe9d --- /dev/null +++ b/local_plugins/chat_api/README.md @@ -0,0 +1,239 @@ +# Chat API Plugin + +基于 MacPaw OpenAI Swift 库的 Flutter 聊天 API 插件。 + +## 功能特性 + +- ✅ 支持 OpenAI Chat API 的完整功能 +- ✅ 流式输出支持 +- ✅ 函数调用支持 +- ✅ 多模态支持(文本、图片) +- ✅ 自定义模型支持 +- ✅ Token 计数估算 +- ✅ 支持自定义 API 端点 +- ✅ 自动 Swift Package Manager (SPM) 集成 + +## 安装 + +### 1. 启用 Swift Package Manager + +确保你的 Flutter 版本 >= 3.24,并启用 SPM 支持: + +```bash +flutter config --enable-swift-package-manager +``` + +### 2. 添加依赖 + +在你的 `pubspec.yaml` 文件中添加: + +```yaml +dependencies: + chat_api: + path: ../local_plugins/chat_api +``` + +### 3. iOS 配置 + +运行以下命令,Flutter 会自动处理 SPM 依赖: + +```bash +cd your_project +flutter pub get +cd ios +flutter run +``` + +首次运行时,Flutter 会自动: +- 迁移项目到 SPM 结构(如果还没有迁移) +- 下载并集成 MacPaw OpenAI Swift 包 +- 配置所有必要的依赖 + +**注意**:不再需要手动在 Xcode 中添加包依赖! + +### 4. Android 配置 + +当前 Android 端仅提供占位实现,如需完整功能请使用 iOS 平台。 + +## 使用方法 + +### 初始化 + +```dart +import 'package:chat_api/chat_api.dart'; + +final chatApi = ChatApi(); + +// 初始化 +await chatApi.initialize( + apiKey: 'your-api-key', + organization: 'your-org-id', // 可选 + model: 'gpt-3.5-turbo', // 默认模型 + baseUrl: 'https://api.openai.com/v1/chat/completions', // 可选,自定义端点 +); +``` + +### 发送消息(非流式) + +```dart +// 创建消息列表 +final messages = [ + ChatMessage( + role: MessageRole.system, + content: '你是一个有帮助的助手。', + ), + ChatMessage( + role: MessageRole.user, + content: '你好,请介绍一下你自己。', + ), +]; + +// 发送消息 +try { + final response = await chatApi.sendMessage( + messages: messages, + temperature: 0.7, + maxTokens: 1000, + ); + print('回复: $response'); +} catch (e) { + print('错误: $e'); +} +``` + +### 发送消息(流式) + +```dart +// 监听事件流 +chatApi.eventStream.listen((event) { + switch (event.type) { + case ChatApiEventType.token: + // 收到新的token + print(event.content); + break; + case ChatApiEventType.complete: + // 对话完成 + print('完成'); + break; + case ChatApiEventType.error: + // 发生错误 + print('错误: ${event.content}'); + break; + case ChatApiEventType.functionCall: + // 函数调用 + print('函数调用: ${event.content}'); + break; + } +}); + +// 发送流式消息 +await chatApi.sendMessageStream( + messages: messages, + temperature: 0.7, +); +``` + +### 函数调用 + +```dart +// 定义函数 +final functions = [ + { + 'name': 'get_weather', + 'description': '获取指定位置的天气', + 'parameters': { + 'type': 'object', + 'properties': { + 'location': { + 'type': 'string', + 'description': '城市名称', + }, + }, + 'required': ['location'], + }, + }, +]; + +// 发送带函数的消息 +await chatApi.sendMessageStream( + messages: messages, + functions: functions, +); +``` + +### 多模态支持 + +```dart +// 发送带图片的消息 +final imageMessage = ChatMessage( + role: MessageRole.user, + content: [ + { + 'type': 'text', + 'text': '这张图片里有什么?', + }, + { + 'type': 'image_url', + 'image_url': { + 'url': 'data:image/jpeg;base64,${base64EncodedImage}', + }, + }, + ], +); +``` + +### 其他功能 + +```dart +// 获取可用模型列表 +final models = await chatApi.getAvailableModels(); + +// 设置模型 +await chatApi.setModel('gpt-4'); + +// 估算token数量 +final tokenCount = await chatApi.countTokens('这是一段测试文本'); + +// 取消流式请求 +await chatApi.cancelStream(); +``` + +## 兼容性说明 + +### Swift Package Manager 项目 + +如果你的项目已启用 SPM(Flutter 3.24+),插件会自动通过 SPM 集成,无需额外配置。 + +### CocoaPods 项目 + +如果你的项目仍在使用 CocoaPods(或有其他插件未迁移到 SPM),插件会自动降级到 CocoaPods 模式。这种情况下,你需要手动在 Xcode 中添加 OpenAI 包依赖。详见 `IOS_INTEGRATION_LEGACY.md`。 + +## 故障排除 + +### 问题:构建失败,提示找不到 OpenAI 模块 + +**解决方案**: +1. 确保已启用 SPM:`flutter config --enable-swift-package-manager` +2. 清理并重新构建: + ```bash + flutter clean + cd ios + rm -rf Pods Podfile.lock + flutter pub get + flutter run + ``` + +### 问题:Xcode 中看不到 Package Dependencies + +**解决方案**: +这可能意味着项目还在使用 CocoaPods 模式。运行 `flutter run` 应该会自动迁移到 SPM。 + +## 注意事项 + +1. **API 密钥安全**:不要将 API 密钥硬编码在代码中,建议使用环境变量或安全存储。 +2. **错误处理**:始终使用 try-catch 包装 API 调用以处理可能的错误。 +3. **流式请求**:记得在不需要时取消流式请求,避免资源浪费。 + +## 许可证 + +MIT License \ No newline at end of file diff --git a/local_plugins/chat_api/SPM_MIGRATION.md b/local_plugins/chat_api/SPM_MIGRATION.md new file mode 100644 index 000000000..3c310f6ce --- /dev/null +++ b/local_plugins/chat_api/SPM_MIGRATION.md @@ -0,0 +1,108 @@ +# Chat API 插件 SPM 迁移说明 + +## 迁移概述 + +`chat_api` 插件已经完成了 Swift Package Manager (SPM) 支持的迁移,现在可以自动处理 OpenAI Swift 包的依赖。 + +## 主要变更 + +### 1. 目录结构调整 + +``` +ios/ +├── chat_api.podspec # 保留,支持双轨模式 +├── Classes/ # 已清空(源码已迁移) +└── chat_api/ # 新增 SPM 结构 + ├── Package.swift # SPM 包定义 + └── Sources/ + └── chat_api/ # 源码新位置 + ├── ChatApiPlugin.swift + └── ChatService.swift +``` + +### 2. Package.swift 配置 + +- 声明了 MacPaw OpenAI 包依赖 +- 自动管理版本(0.4.0+) +- 设置最低 iOS 版本为 13.0 + +### 3. podspec 更新 + +- 更新了 `source_files` 路径指向新的 SPM 结构 +- 添加了友好的迁移提示脚本 + +### 4. pubspec.yaml 更新 + +- 添加了 `dartPluginClass` 声明,明确支持 SPM + +## 使用方式对比 + +### 旧方式(手动集成) + +1. 运行 `pod install` +2. 打开 Xcode +3. 手动添加 OpenAI Swift Package +4. 配置依赖关系 + +### 新方式(自动集成) + +1. 启用 SPM:`flutter config --enable-swift-package-manager` +2. 运行 `flutter run` +3. 完成!Flutter 自动处理所有依赖 + +## 兼容性 + +- **Flutter 3.24+**:完整 SPM 支持,推荐使用 +- **Flutter < 3.24**:自动降级到 CocoaPods 模式 +- **混合项目**:如果项目中有其他插件未迁移到 SPM,Flutter 会智能选择合适的集成方式 + +## 验证集成 + +### SPM 项目 + +```bash +# 在 example 目录下 +flutter clean +flutter pub get +flutter run --verbose +``` + +成功标志: +- 看到 "Adding Swift Package Manager integration..." 消息 +- Xcode 中 Package Dependencies 自动出现 OpenAI 包 +- 构建成功,无需手动干预 + +### CocoaPods 项目 + +如果项目仍使用 CocoaPods,会看到: +- podspec 中的迁移提示 +- 需要按照 `IOS_INTEGRATION_LEGACY.md` 手动添加依赖 + +## 故障排除 + +1. **清理缓存** + ```bash + flutter clean + rm -rf ios/Pods ios/Podfile.lock + rm -rf ~/Library/Developer/Xcode/DerivedData + ``` + +2. **重置 SPM 缓存** + - Xcode: File > Packages > Reset Package Caches + +3. **确认 SPM 已启用** + ```bash + flutter config + # 应该看到 enable-swift-package-manager: true + ``` + +## 开发者注意事项 + +1. **源码位置**:所有 Swift 代码现在位于 `ios/chat_api/Sources/chat_api/` +2. **双轨支持**:保持 podspec 和 Package.swift 同时更新 +3. **版本管理**:OpenAI 包版本在 Package.swift 中管理 + +## 未来计划 + +- 当 Flutter 生态系统完全迁移到 SPM 后,可以考虑移除 CocoaPods 支持 +- 持续跟踪 MacPaw OpenAI 库的更新 \ No newline at end of file diff --git a/local_plugins/chat_api/android/build.gradle b/local_plugins/chat_api/android/build.gradle new file mode 100644 index 000000000..52aeb5764 --- /dev/null +++ b/local_plugins/chat_api/android/build.gradle @@ -0,0 +1,64 @@ +group 'com.yunqiinnovation.chat_api' +version '1.0' + +buildscript { + ext.kotlin_version = '1.7.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + test.java.srcDirs += 'src/test/kotlin' + } + + defaultConfig { + minSdkVersion 21 + } + + dependencies { + testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'org.mockito:mockito-core:5.0.0' + } + + testOptions { + unitTests.all { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + outputs.upToDateWhen {false} + showStandardStreams = true + } + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/android/src/main/AndroidManifest.xml b/local_plugins/chat_api/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f73b083ed --- /dev/null +++ b/local_plugins/chat_api/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + \ No newline at end of file diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt new file mode 100644 index 000000000..8265df2d4 --- /dev/null +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiPlugin.kt @@ -0,0 +1,71 @@ +package com.yunqiinnovation.chat_api + +import androidx.annotation.NonNull +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.EventChannel + +/** ChatApiPlugin */ +class ChatApiPlugin: FlutterPlugin, MethodCallHandler, EventChannel.StreamHandler { + /// The MethodChannel that will the communication between Flutter and native Android + /// + /// This local reference serves to register the plugin with the Flutter Engine and unregister it + /// when the Flutter Engine is detached from the Activity + private lateinit var methodChannel : MethodChannel + private lateinit var eventChannel : EventChannel + private var eventSink: EventChannel.EventSink? = null + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.chat_api/methods") + methodChannel.setMethodCallHandler(this) + + eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.yunqiinnovation.chat_api/events") + eventChannel.setStreamHandler(this) + } + + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "initialize" -> { + // TODO: Implement Android version using OkHttp or Retrofit + result.success(true) + } + "sendMessage" -> { + result.error("NOT_IMPLEMENTED", "Android implementation not available", null) + } + "sendMessageStream" -> { + result.error("NOT_IMPLEMENTED", "Android implementation not available", null) + } + "cancelStream" -> { + result.success(true) + } + "getAvailableModels" -> { + result.success(emptyList()) + } + "setModel" -> { + result.success(true) + } + "countTokens" -> { + result.success(0) + } + else -> { + result.notImplemented() + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + methodChannel.setMethodCallHandler(null) + eventChannel.setStreamHandler(null) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/example/lib/main.dart b/local_plugins/chat_api/example/lib/main.dart new file mode 100644 index 000000000..947d92dde --- /dev/null +++ b/local_plugins/chat_api/example/lib/main.dart @@ -0,0 +1,266 @@ +import 'package:flutter/material.dart'; +import 'package:chat_api/chat_api.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Chat API Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const ChatDemo(), + ); + } +} + +class ChatDemo extends StatefulWidget { + const ChatDemo({Key? key}) : super(key: key); + + @override + State createState() => _ChatDemoState(); +} + +class _ChatDemoState extends State { + final ChatApi _chatApi = ChatApi(); + final TextEditingController _controller = TextEditingController(); + final List _messages = []; + String _response = ''; + bool _isLoading = false; + bool _isStreaming = false; + + @override + void initState() { + super.initState(); + _initializeChatApi(); + _listenToStream(); + } + + Future _initializeChatApi() async { + try { + // 注意:请替换为你自己的API密钥 + await _chatApi.initialize( + apiKey: 'your-api-key-here', + model: 'gpt-3.5-turbo', + ); + print('Chat API 初始化成功'); + } catch (e) { + print('初始化失败: $e'); + } + } + + void _listenToStream() { + _chatApi.eventStream.listen((event) { + switch (event.type) { + case ChatApiEventType.token: + setState(() { + _response += event.content; + }); + break; + case ChatApiEventType.complete: + setState(() { + _isStreaming = false; + _messages.add(ChatMessage( + role: MessageRole.assistant, + content: _response, + )); + }); + break; + case ChatApiEventType.error: + setState(() { + _isStreaming = false; + _response = '错误: ${event.content}'; + }); + break; + case ChatApiEventType.functionCall: + print('函数调用: ${event.content}'); + break; + } + }); + } + + Future _sendMessage() async { + if (_controller.text.isEmpty) return; + + final userMessage = ChatMessage( + role: MessageRole.user, + content: _controller.text, + ); + + setState(() { + _messages.add(userMessage); + _response = ''; + _isStreaming = true; + }); + + _controller.clear(); + + try { + await _chatApi.sendMessageStream( + messages: _messages, + temperature: 0.7, + ); + } catch (e) { + setState(() { + _isStreaming = false; + _response = '发送失败: $e'; + }); + } + } + + Future _sendNonStreamMessage() async { + if (_controller.text.isEmpty) return; + + final userMessage = ChatMessage( + role: MessageRole.user, + content: _controller.text, + ); + + setState(() { + _messages.add(userMessage); + _isLoading = true; + }); + + _controller.clear(); + + try { + final response = await _chatApi.sendMessage( + messages: _messages, + temperature: 0.7, + ); + + setState(() { + _messages.add(ChatMessage( + role: MessageRole.assistant, + content: response, + )); + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('发送失败: $e')), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Chat API Demo'), + actions: [ + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + setState(() { + _messages.clear(); + _response = ''; + }); + }, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _messages.length + (_isStreaming ? 1 : 0), + itemBuilder: (context, index) { + if (index == _messages.length && _isStreaming) { + return _buildMessage( + MessageRole.assistant, + _response.isEmpty ? '...' : _response, + ); + } + final message = _messages[index]; + return _buildMessage(message.role, message.content.toString()); + }, + ), + ), + if (_isLoading) + const LinearProgressIndicator(), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[100], + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: '输入消息...', + border: OutlineInputBorder(), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.send), + onPressed: _isStreaming || _isLoading ? null : _sendMessage, + color: Colors.blue, + ), + IconButton( + icon: const Icon(Icons.send_and_archive), + onPressed: _isStreaming || _isLoading ? null : _sendNonStreamMessage, + color: Colors.green, + tooltip: '非流式发送', + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMessage(MessageRole role, String content) { + final isUser = role == MessageRole.user; + return Container( + margin: const EdgeInsets.only(bottom: 8), + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isUser ? Colors.blue : Colors.grey[300], + borderRadius: BorderRadius.circular(16), + ), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.7, + ), + child: Text( + content, + style: TextStyle( + color: isUser ? Colors.white : Colors.black87, + ), + ), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + _chatApi.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/example/pubspec.yaml b/local_plugins/chat_api/example/pubspec.yaml new file mode 100644 index 000000000..4ff37b017 --- /dev/null +++ b/local_plugins/chat_api/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: chat_api_example +description: Demonstrates how to use the chat_api plugin. + +version: 1.0.0+1 + +environment: + sdk: '>=2.17.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + chat_api: + path: ../ + + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + uses-material-design: true \ No newline at end of file diff --git a/local_plugins/chat_api/ios/chat_api.podspec b/local_plugins/chat_api/ios/chat_api.podspec new file mode 100644 index 000000000..872af925e --- /dev/null +++ b/local_plugins/chat_api/ios/chat_api.podspec @@ -0,0 +1,44 @@ +Pod::Spec.new do |s| + s.name = 'chat_api' + s.version = '0.0.1' + s.summary = 'Chat API plugin based on MacPaw OpenAI Swift library' + s.description = <<-DESC +A Flutter plugin that provides chat API functionality using MacPaw OpenAI Swift library. +Supports streaming, function calling and all OpenAI chat features. + DESC + s.homepage = 'https://github.com/yunqiinnovation/deep_voice' + s.license = { :file => '../LICENSE' } + s.author = { 'Yunqi Innovation' => 'support@yunqiinnovation.com' } + s.source = { :path => '.' } + + # 重要:更新源文件路径以支持SPM结构 + # 如果是通过SPM集成,Flutter会自动使用Package.swift + # 如果是通过CocoaPods集成,使用这里的路径 + s.source_files = 'chat_api/Sources/chat_api/**/*' + + s.dependency 'Flutter' + s.platform = :ios, '13.0' + s.swift_version = '5.0' + + # Flutter.framework does not contain a i386 slice + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386', + 'ENABLE_BITCODE' => 'NO' + } + + # 保留这个提示,以防用户仍在使用纯CocoaPods项目 + s.script_phases = [ + { + :name => 'SPM Migration Notice', + :script => <<-SCRIPT + echo "========================================" + echo "Chat API Plugin Notice:" + echo "This plugin now supports Swift Package Manager (SPM)." + echo "For best results, ensure your Flutter project has SPM enabled:" + echo " flutter config --enable-swift-package-manager" + echo "========================================" + SCRIPT + } + ] +end \ No newline at end of file diff --git a/local_plugins/chat_api/ios/chat_api/Package.swift b/local_plugins/chat_api/ios/chat_api/Package.swift new file mode 100644 index 000000000..619c46e0a --- /dev/null +++ b/local_plugins/chat_api/ios/chat_api/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "chat_api", + platforms: [ + .iOS(.v13) // 与podspec中的版本保持一致 + ], + products: [ + .library(name: "chat-api", targets: ["ChatApi"]) + ], + // 声明MacPaw OpenAI Swift包依赖 + dependencies: [ + .package( + url: "https://github.com/MacPaw/OpenAI.git", + .upToNextMajor(from: "0.4.0") + ) + ], + targets: [ + .target( + name: "ChatApi", + // 将OpenAI产品添加到target依赖 + dependencies: [ + .product(name: "OpenAI", package: "OpenAI") + ], + path: "Sources/chat_api", + resources: [ + // 如果有资源文件,在这里声明 + ] + ) + ] +) \ No newline at end of file diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift new file mode 100644 index 000000000..694f3cdbd --- /dev/null +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatApiPlugin.swift @@ -0,0 +1,316 @@ +import Flutter +import UIKit +import Foundation +import OpenAI + +/// 聊天API插件主类 +public class ChatApiPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { + + // MARK: - 常量 + private static let methodChannelName = "com.yunqiinnovation.chat_api/methods" + private static let eventChannelName = "com.yunqiinnovation.chat_api/events" + + // MARK: - 属性 + private var methodChannel: FlutterMethodChannel? + private var eventChannel: FlutterEventChannel? + private var eventSink: FlutterEventSink? + private var chatService: ChatService? + + // MARK: - 插件注册 + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = ChatApiPlugin() + + // 初始化方法通道 + let methodChannel = FlutterMethodChannel( + name: methodChannelName, + binaryMessenger: registrar.messenger() + ) + instance.methodChannel = methodChannel + registrar.addMethodCallDelegate(instance, channel: methodChannel) + + // 初始化事件通道 + let eventChannel = FlutterEventChannel( + name: eventChannelName, + binaryMessenger: registrar.messenger() + ) + instance.eventChannel = eventChannel + eventChannel.setStreamHandler(instance) + + // 初始化聊天服务 + instance.chatService = ChatService() + } + + // MARK: - FlutterPlugin + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard let service = chatService else { + result(FlutterError(code: "SERVICE_NOT_INITIALIZED", + message: "聊天服务未初始化", + details: nil)) + return + } + + switch call.method { + case "initialize": + handleInitialize(call: call, result: result, service: service) + + case "sendMessage": + handleSendMessage(call: call, result: result, service: service) + + case "sendMessageStream": + handleSendMessageStream(call: call, result: result, service: service) + + case "cancelStream": + handleCancelStream(call: call, result: result, service: service) + + case "getAvailableModels": + handleGetAvailableModels(call: call, result: result, service: service) + + case "setModel": + handleSetModel(call: call, result: result, service: service) + + case "countTokens": + handleCountTokens(call: call, result: result, service: service) + + default: + result(FlutterMethodNotImplemented) + } + } + + // MARK: - FlutterStreamHandler + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } + + // MARK: - 方法处理 + + private func handleInitialize(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + guard let args = call.arguments as? [String: Any], + let apiKey = args["apiKey"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", + message: "缺少API密钥参数", + details: nil)) + return + } + + let organization = args["organization"] as? String + let model = args["model"] as? String ?? "gpt-3.5-turbo" + let baseUrl = args["baseUrl"] as? String + + Task { + do { + let success = try await service.initialize( + apiKey: apiKey, + organization: organization, + model: model, + baseUrl: baseUrl + ) + DispatchQueue.main.async { + result(success) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "INIT_ERROR", + message: "初始化失败: \(error.localizedDescription)", + details: nil)) + } + } + } + } + + private func handleSendMessage(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + guard let args = call.arguments as? [String: Any], + let messagesArray = args["messages"] as? [[String: Any]] else { + result(FlutterError(code: "INVALID_ARGUMENTS", + message: "缺少消息参数", + details: nil)) + return + } + + let parameters = extractChatParameters(from: args) + + Task { + do { + let response = try await service.sendMessage( + messages: messagesArray, + parameters: parameters + ) + DispatchQueue.main.async { + result(response) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "CHAT_ERROR", + message: "发送消息失败: \(error.localizedDescription)", + details: nil)) + } + } + } + } + + private func handleSendMessageStream(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + guard let args = call.arguments as? [String: Any], + let messagesArray = args["messages"] as? [[String: Any]] else { + result(FlutterError(code: "INVALID_ARGUMENTS", + message: "缺少消息参数", + details: nil)) + return + } + + guard let eventSink = self.eventSink else { + result(FlutterError(code: "NO_EVENT_SINK", + message: "没有可用的事件流接收器", + details: nil)) + return + } + + let parameters = extractChatParameters(from: args) + + // 创建流式回调处理器 + let streamHandler = StreamEventHandler(eventSink: eventSink) + + Task { + do { + await service.sendMessageStream( + messages: messagesArray, + parameters: parameters, + handler: streamHandler + ) + DispatchQueue.main.async { + result(true) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "STREAM_ERROR", + message: "发送流式消息失败: \(error.localizedDescription)", + details: nil)) + } + } + } + } + + private func handleCancelStream(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + service.cancelStream() + result(true) + } + + private func handleGetAvailableModels(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + Task { + do { + let models = try await service.getAvailableModels() + DispatchQueue.main.async { + result(models) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "MODEL_ERROR", + message: "获取模型列表失败: \(error.localizedDescription)", + details: nil)) + } + } + } + } + + private func handleSetModel(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + guard let args = call.arguments as? [String: Any], + let model = args["model"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", + message: "缺少模型参数", + details: nil)) + return + } + + service.setModel(model) + result(true) + } + + private func handleCountTokens(call: FlutterMethodCall, result: @escaping FlutterResult, service: ChatService) { + guard let args = call.arguments as? [String: Any], + let text = args["text"] as? String else { + result(FlutterError(code: "INVALID_ARGUMENTS", + message: "缺少文本参数", + details: nil)) + return + } + + // 简单的token估算(实际应该使用tiktoken或类似库) + let estimatedTokens = service.estimateTokens(text: text) + result(estimatedTokens) + } + + // MARK: - 辅助方法 + + private func extractChatParameters(from args: [String: Any]) -> ChatParameters { + return ChatParameters( + temperature: args["temperature"] as? Double ?? 0.7, + maxTokens: args["maxTokens"] as? Int, + topP: args["topP"] as? Double ?? 1.0, + n: args["n"] as? Int ?? 1, + stop: args["stop"] as? [String], + presencePenalty: args["presencePenalty"] as? Double ?? 0.0, + frequencyPenalty: args["frequencyPenalty"] as? Double ?? 0.0, + functions: args["functions"] as? [[String: Any]] + ) + } +} + +// MARK: - 聊天参数 +struct ChatParameters { + let temperature: Double + let maxTokens: Int? + let topP: Double + let n: Int + let stop: [String]? + let presencePenalty: Double + let frequencyPenalty: Double + let functions: [[String: Any]]? +} + +// MARK: - 流式事件处理器 +private class StreamEventHandler: ChatStreamHandler { + private let eventSink: FlutterEventSink + + init(eventSink: @escaping FlutterEventSink) { + self.eventSink = eventSink + } + + func onToken(_ token: String) { + DispatchQueue.main.async { + self.eventSink([ + "type": "token", + "content": token + ]) + } + } + + func onComplete() { + DispatchQueue.main.async { + self.eventSink([ + "type": "complete" + ]) + } + } + + func onError(_ error: Error) { + DispatchQueue.main.async { + self.eventSink([ + "type": "error", + "content": error.localizedDescription + ]) + } + } + + func onFunctionCall(_ functionCall: [String: Any]) { + DispatchQueue.main.async { + self.eventSink([ + "type": "functionCall", + "content": functionCall + ]) + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatService.swift b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatService.swift new file mode 100644 index 000000000..2516d8462 --- /dev/null +++ b/local_plugins/chat_api/ios/chat_api/Sources/chat_api/ChatService.swift @@ -0,0 +1,363 @@ +import Foundation +import OpenAI + +/// 聊天流处理器协议 +protocol ChatStreamHandler { + func onToken(_ token: String) + func onComplete() + func onError(_ error: Error) + func onFunctionCall(_ functionCall: [String: Any]) +} + +/// 聊天服务 +class ChatService { + + // MARK: - 属性 + private var openAI: OpenAI? + private var currentModel: Model = .gpt3_5Turbo + private var currentTask: Task? + + // MARK: - 初始化 + + /// 初始化OpenAI客户端 + func initialize(apiKey: String, organization: String?, model: String, baseUrl: String?) async throws -> Bool { + // 创建配置 + var configuration = OpenAI.Configuration(token: apiKey) + + // 设置组织ID + if let organization = organization { + configuration.organizationIdentifier = organization + } + + // 设置自定义基础URL + if let baseUrl = baseUrl, let url = URL(string: baseUrl) { + configuration.url = url + } + + // 创建OpenAI客户端 + openAI = OpenAI(configuration: configuration) + + // 设置模型 + setModel(model) + + return true + } + + // MARK: - 发送消息 + + /// 发送消息(非流式) + func sendMessage(messages: [[String: Any]], parameters: ChatParameters) async throws -> String { + guard let openAI = openAI else { + throw ChatApiError.notInitialized + } + + // 转换消息格式 + let chatMessages = try messages.map { dict -> Chat in + guard let role = dict["role"] as? String, + let content = dict["content"] else { + throw ChatApiError.invalidMessage + } + + // 创建聊天角色 + let chatRole = try parseChatRole(role) + + // 处理不同类型的内容 + if let contentString = content as? String { + return Chat(role: chatRole, content: contentString) + } else if let contentArray = content as? [[String: Any]] { + // 处理多模态内容(如图片) + var contentItems: [ChatContent] = [] + for item in contentArray { + if let type = item["type"] as? String { + switch type { + case "text": + if let text = item["text"] as? String { + contentItems.append(.text(text)) + } + case "image_url": + if let imageUrl = item["image_url"] as? [String: Any], + let url = imageUrl["url"] as? String { + contentItems.append(.image(.init(url: url))) + } + default: + break + } + } + } + return Chat(role: chatRole, content: contentItems) + } else { + throw ChatApiError.invalidMessage + } + } + + // 构建查询 + var query = ChatQuery( + messages: chatMessages, + model: currentModel, + temperature: parameters.temperature, + topP: parameters.topP, + n: parameters.n, + stop: parameters.stop ?? [], + maxTokens: parameters.maxTokens, + presencePenalty: parameters.presencePenalty, + frequencyPenalty: parameters.frequencyPenalty + ) + + // 添加函数 + if let functions = parameters.functions { + query.tools = try functions.map { dict in + guard let name = dict["name"] as? String, + let description = dict["description"] as? String, + let parameters = dict["parameters"] as? [String: Any] else { + throw ChatApiError.invalidFunction + } + + return ChatQuery.ChatCompletionToolParam( + function: .init( + name: name, + description: description, + parameters: parameters + ) + ) + } + } + + // 发送请求 + let result = try await openAI.chats(query: query) + + // 处理函数调用 + if let toolCalls = result.choices.first?.message.toolCalls, + let toolCall = toolCalls.first { + // 返回函数调用信息 + let functionCall: [String: Any] = [ + "name": toolCall.function.name, + "arguments": toolCall.function.arguments ?? "", + "id": toolCall.id + ] + + if let jsonData = try? JSONSerialization.data(withJSONObject: functionCall), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + } + + // 返回普通消息内容 + return result.choices.first?.message.content?.string ?? "" + } + + /// 发送消息(流式) + func sendMessageStream(messages: [[String: Any]], parameters: ChatParameters, handler: ChatStreamHandler) async { + // 取消之前的任务 + currentTask?.cancel() + + currentTask = Task { + do { + guard let openAI = openAI else { + handler.onError(ChatApiError.notInitialized) + return + } + + // 转换消息格式(与非流式相同) + let chatMessages = try messages.map { dict -> Chat in + guard let role = dict["role"] as? String, + let content = dict["content"] else { + throw ChatApiError.invalidMessage + } + + let chatRole = try parseChatRole(role) + + if let contentString = content as? String { + return Chat(role: chatRole, content: contentString) + } else if let contentArray = content as? [[String: Any]] { + var contentItems: [ChatContent] = [] + for item in contentArray { + if let type = item["type"] as? String { + switch type { + case "text": + if let text = item["text"] as? String { + contentItems.append(.text(text)) + } + case "image_url": + if let imageUrl = item["image_url"] as? [String: Any], + let url = imageUrl["url"] as? String { + contentItems.append(.image(.init(url: url))) + } + default: + break + } + } + } + return Chat(role: chatRole, content: contentItems) + } else { + throw ChatApiError.invalidMessage + } + } + + // 构建查询 + var query = ChatQuery( + messages: chatMessages, + model: currentModel, + temperature: parameters.temperature, + topP: parameters.topP, + n: parameters.n, + stop: parameters.stop ?? [], + maxTokens: parameters.maxTokens, + presencePenalty: parameters.presencePenalty, + frequencyPenalty: parameters.frequencyPenalty + ) + + // 添加函数 + if let functions = parameters.functions { + query.tools = try functions.map { dict in + guard let name = dict["name"] as? String, + let description = dict["description"] as? String, + let parameters = dict["parameters"] as? [String: Any] else { + throw ChatApiError.invalidFunction + } + + return ChatQuery.ChatCompletionToolParam( + function: .init( + name: name, + description: description, + parameters: parameters + ) + ) + } + } + + // 创建流式请求 + let stream = openAI.chatsStream(query: query) + + // 处理流式响应 + for try await result in stream { + // 检查任务是否被取消 + if Task.isCancelled { + break + } + + // 处理增量内容 + if let choice = result.choices.first { + // 处理文本内容 + if let content = choice.delta?.content { + handler.onToken(content) + } + + // 处理函数调用 + if let toolCalls = choice.delta?.toolCalls, + let toolCall = toolCalls.first { + var functionCall: [String: Any] = [:] + + if let name = toolCall.function?.name { + functionCall["name"] = name + } + if let arguments = toolCall.function?.arguments { + functionCall["arguments"] = arguments + } + if let id = toolCall.id { + functionCall["id"] = id + } + + if !functionCall.isEmpty { + handler.onFunctionCall(functionCall) + } + } + + // 检查是否完成 + if choice.finishReason != nil { + handler.onComplete() + } + } + } + + } catch { + handler.onError(error) + } + } + } + + /// 取消流式请求 + func cancelStream() { + currentTask?.cancel() + currentTask = nil + } + + // MARK: - 模型管理 + + /// 获取可用的模型列表 + func getAvailableModels() async throws -> [String] { + guard let openAI = openAI else { + throw ChatApiError.notInitialized + } + + let models = try await openAI.models() + return models.data.map { $0.id } + } + + /// 设置当前模型 + func setModel(_ modelString: String) { + // 尝试解析标准模型 + if let model = Model(rawValue: modelString) { + currentModel = model + } else { + // 使用自定义模型 + currentModel = Model.custom(modelString) + } + } + + /// 估算token数量(简单实现) + func estimateTokens(text: String) -> Int { + // 粗略估算:1个token约等于4个字符或0.75个单词 + // 中文通常1个字符算1个token + let words = text.split(separator: " ").count + let chars = text.count + + // 检测是否包含中文 + let hasChineseCharacters = text.range(of: "\\p{Han}", options: .regularExpression) != nil + + if hasChineseCharacters { + // 中文为主的文本,按字符数估算 + return chars + } else { + // 英文为主的文本,按单词数估算 + return Int(Double(words) * 1.3) + } + } + + // MARK: - 辅助方法 + + private func parseChatRole(_ role: String) throws -> Chat.Role { + switch role.lowercased() { + case "system": + return .system + case "user": + return .user + case "assistant": + return .assistant + case "function": + return .function(name: nil) + default: + throw ChatApiError.invalidRole(role) + } + } +} + +// MARK: - 错误定义 +enum ChatApiError: LocalizedError { + case notInitialized + case invalidMessage + case invalidFunction + case invalidRole(String) + + var errorDescription: String? { + switch self { + case .notInitialized: + return "OpenAI客户端未初始化" + case .invalidMessage: + return "无效的消息格式" + case .invalidFunction: + return "无效的函数定义" + case .invalidRole(let role): + return "无效的角色类型: \(role)" + } + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/lib/chat_api.dart b/local_plugins/chat_api/lib/chat_api.dart new file mode 100644 index 000000000..1e5cb6e59 --- /dev/null +++ b/local_plugins/chat_api/lib/chat_api.dart @@ -0,0 +1,358 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; + +/// 聊天API异常 +class ChatApiException implements Exception { + final String message; + + ChatApiException(this.message); + + @override + String toString() => 'ChatApiException: $message'; +} + +/// 聊天API事件类型 +enum ChatApiEventType { + /// 收到新的token + token, + /// 对话完成 + complete, + /// 发生错误 + error, + /// 函数调用 + functionCall, +} + +/// 聊天API事件 +class ChatApiEvent { + final ChatApiEventType type; + final dynamic content; + final Map? meta; + + ChatApiEvent({ + required this.type, + this.content, + this.meta, + }); + + factory ChatApiEvent.fromMap(Map map) { + final typeStr = map['type'] as String; + final content = map['content']; + final meta = map['meta'] as Map?; + + return ChatApiEvent( + type: _typeFromString(typeStr), + content: content, + meta: meta, + ); + } + + static ChatApiEventType _typeFromString(String typeStr) { + switch (typeStr) { + case 'token': + return ChatApiEventType.token; + case 'complete': + return ChatApiEventType.complete; + case 'error': + return ChatApiEventType.error; + case 'functionCall': + return ChatApiEventType.functionCall; + default: + throw ArgumentError('未知的事件类型: $typeStr'); + } + } +} + +/// 消息角色 +enum MessageRole { + system, + user, + assistant, + function, +} + +extension MessageRoleExtension on MessageRole { + String get value { + switch (this) { + case MessageRole.system: + return 'system'; + case MessageRole.user: + return 'user'; + case MessageRole.assistant: + return 'assistant'; + case MessageRole.function: + return 'function'; + } + } +} + +/// 聊天消息 +class ChatMessage { + final MessageRole role; + final dynamic content; + final String? name; + final Map? functionCall; + + ChatMessage({ + required this.role, + required this.content, + this.name, + this.functionCall, + }); + + Map toMap() { + final map = { + 'role': role.value, + 'content': content, + }; + if (name != null) { + map['name'] = name!; + } + if (functionCall != null) { + map['function_call'] = functionCall!; + } + return map; + } + + factory ChatMessage.fromMap(Map map) { + return ChatMessage( + role: _roleFromString(map['role'] as String), + content: map['content'], + name: map['name'] as String?, + functionCall: map['function_call'] as Map?, + ); + } + + static MessageRole _roleFromString(String roleStr) { + switch (roleStr) { + case 'system': + return MessageRole.system; + case 'user': + return MessageRole.user; + case 'assistant': + return MessageRole.assistant; + case 'function': + return MessageRole.function; + default: + throw ArgumentError('未知的角色类型: $roleStr'); + } + } +} + +/// 聊天API插件 +class ChatApi { + static const MethodChannel _channel = + MethodChannel('com.yunqiinnovation.chat_api/methods'); + static const EventChannel _eventChannel = + EventChannel('com.yunqiinnovation.chat_api/events'); + + /// 事件流控制器 + StreamController? _eventStreamController; + + /// 事件流 + Stream? _eventStream; + + /// 获取事件流 + Stream get eventStream { + if (_eventStream == null) { + _eventStreamController = StreamController.broadcast(); + _eventStream = _eventStreamController!.stream; + + // 监听原生事件 + _eventChannel.receiveBroadcastStream().listen( + (dynamic event) { + if (event is Map) { + final eventMap = Map.from(event); + final chatApiEvent = ChatApiEvent.fromMap(eventMap); + _eventStreamController!.add(chatApiEvent); + } + }, + onError: (error) { + _eventStreamController!.addError(ChatApiException('事件流错误: $error')); + }, + ); + } + + return _eventStream!; + } + + /// 初始化聊天API服务 + /// + /// [apiKey] OpenAI API密钥 + /// [organization] 可选,组织ID + /// [model] 可选,使用的模型,默认为gpt-3.5-turbo + /// [baseUrl] 可选,自定义API基础URL + Future initialize({ + required String apiKey, + String? organization, + String model = 'gpt-3.5-turbo', + String? baseUrl, + }) async { + try { + final result = await _channel.invokeMethod( + 'initialize', + { + 'apiKey': apiKey, + 'organization': organization, + 'model': model, + 'baseUrl': baseUrl, + }, + ); + + return result ?? false; + } catch (e) { + throw ChatApiException('初始化失败: $e'); + } + } + + /// 发送聊天消息 + /// + /// [messages] 消息列表 + /// [temperature] 温度参数,控制输出的随机性 + /// [maxTokens] 最大token数 + /// [topP] 核心采样参数 + /// [n] 生成的回复数量 + /// [stop] 停止序列 + /// [presencePenalty] 存在惩罚 + /// [frequencyPenalty] 频率惩罚 + /// [functions] 可用的函数列表 + Future sendMessage({ + required List messages, + double temperature = 0.7, + int? maxTokens, + double topP = 1.0, + int n = 1, + List? stop, + double presencePenalty = 0.0, + double frequencyPenalty = 0.0, + List>? functions, + }) async { + try { + final messagesMap = messages.map((m) => m.toMap()).toList(); + + final result = await _channel.invokeMethod( + 'sendMessage', + { + 'messages': messagesMap, + 'temperature': temperature, + 'maxTokens': maxTokens, + 'topP': topP, + 'n': n, + 'stop': stop, + 'presencePenalty': presencePenalty, + 'frequencyPenalty': frequencyPenalty, + 'functions': functions, + }, + ); + + if (result == null) { + throw ChatApiException('发送消息失败: 结果为空'); + } + + return result; + } catch (e) { + throw ChatApiException('发送消息失败: $e'); + } + } + + /// 发送聊天消息(流式输出) + /// + /// [messages] 消息列表 + /// [temperature] 温度参数,控制输出的随机性 + /// [maxTokens] 最大token数 + /// [topP] 核心采样参数 + /// [n] 生成的回复数量 + /// [stop] 停止序列 + /// [presencePenalty] 存在惩罚 + /// [frequencyPenalty] 频率惩罚 + /// [functions] 可用的函数列表 + Future sendMessageStream({ + required List messages, + double temperature = 0.7, + int? maxTokens, + double topP = 1.0, + int n = 1, + List? stop, + double presencePenalty = 0.0, + double frequencyPenalty = 0.0, + List>? functions, + }) async { + try { + final messagesMap = messages.map((m) => m.toMap()).toList(); + + final result = await _channel.invokeMethod( + 'sendMessageStream', + { + 'messages': messagesMap, + 'temperature': temperature, + 'maxTokens': maxTokens, + 'topP': topP, + 'n': n, + 'stop': stop, + 'presencePenalty': presencePenalty, + 'frequencyPenalty': frequencyPenalty, + 'functions': functions, + }, + ); + + return result ?? false; + } catch (e) { + throw ChatApiException('发送流式消息失败: $e'); + } + } + + /// 取消当前的流式请求 + Future cancelStream() async { + try { + final result = await _channel.invokeMethod('cancelStream'); + return result ?? false; + } catch (e) { + throw ChatApiException('取消流式请求失败: $e'); + } + } + + /// 获取可用的模型列表 + Future> getAvailableModels() async { + try { + final result = await _channel.invokeMethod>('getAvailableModels'); + return result?.cast() ?? []; + } catch (e) { + throw ChatApiException('获取模型列表失败: $e'); + } + } + + /// 设置默认模型 + Future setModel(String model) async { + try { + final result = await _channel.invokeMethod( + 'setModel', + {'model': model}, + ); + return result ?? false; + } catch (e) { + throw ChatApiException('设置模型失败: $e'); + } + } + + /// 估算token数量 + Future countTokens(String text) async { + try { + final result = await _channel.invokeMethod( + 'countTokens', + {'text': text}, + ); + return result ?? 0; + } catch (e) { + throw ChatApiException('计算token失败: $e'); + } + } + + /// 释放资源 + Future dispose() async { + await _eventStreamController?.close(); + _eventStreamController = null; + _eventStream = null; + } +} \ No newline at end of file diff --git a/local_plugins/chat_api/pubspec.yaml b/local_plugins/chat_api/pubspec.yaml new file mode 100644 index 000000000..7917fd475 --- /dev/null +++ b/local_plugins/chat_api/pubspec.yaml @@ -0,0 +1,29 @@ +name: chat_api +description: 基于MacPaw OpenAI Swift库的聊天API插件,支持流式输出和函数调用。 +version: 0.0.1 +homepage: https://github.com/yunqiinnovation/deep_voice + +environment: + sdk: ">=2.17.0 <4.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + plugin: + platforms: + ios: + pluginClass: ChatApiPlugin + sharedDarwinSource: true + # 明确声明支持SPM + dartPluginClass: ChatApi + android: + package: com.yunqiinnovation.chat_api + pluginClass: ChatApiPlugin \ No newline at end of file