From 3b028befdeaf8118dc55c7e13d5e1c9c5e626860 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 9 Oct 2025 17:24:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?Revert=20"feat(ios):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9F=BA=E4=BA=8EReplayKit=E7=9A=84=E5=85=A8=E5=B1=80=E6=82=AC?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E5=8A=9F=E8=83=BD"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 26ffe461a0e1f59986c3a55897d25dc675e6ae0d. --- .../BroadcastExtension.entitlements | 10 - ios/BroadcastExtension/Info.plist | 38 -- ios/BroadcastExtension/SampleHandler.swift | 216 ----------- ios/ReplayKit_Implementation_Summary.md | 180 --------- ios/ReplayKit_Integration_Guide.md | 142 -------- ios/Runner/AppDelegate.swift | 10 - ios/Runner/FloatingWindow.swift | 341 ------------------ ios/Runner/FloatingWindowPlugin.swift | 154 -------- ios/Runner/OverlayPermissionPlugin.swift | 40 -- ios/Runner/ReplayKitManager.swift | 247 ------------- ios/Runner/Runner.entitlements | 4 - lib/core/utils/overlay_permission_util.dart | 15 +- test/ios_floating_window_test.dart | 149 -------- 13 files changed, 3 insertions(+), 1543 deletions(-) delete mode 100644 ios/BroadcastExtension/BroadcastExtension.entitlements delete mode 100644 ios/BroadcastExtension/Info.plist delete mode 100644 ios/BroadcastExtension/SampleHandler.swift delete mode 100644 ios/ReplayKit_Implementation_Summary.md delete mode 100644 ios/ReplayKit_Integration_Guide.md delete mode 100644 ios/Runner/FloatingWindow.swift delete mode 100644 ios/Runner/FloatingWindowPlugin.swift delete mode 100644 ios/Runner/OverlayPermissionPlugin.swift delete mode 100644 ios/Runner/ReplayKitManager.swift delete mode 100644 test/ios_floating_window_test.dart diff --git a/ios/BroadcastExtension/BroadcastExtension.entitlements b/ios/BroadcastExtension/BroadcastExtension.entitlements deleted file mode 100644 index ea41065d0..000000000 --- a/ios/BroadcastExtension/BroadcastExtension.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.saitong.voitrans.shared - - - diff --git a/ios/BroadcastExtension/Info.plist b/ios/BroadcastExtension/Info.plist deleted file mode 100644 index 7c4ad275a..000000000 --- a/ios/BroadcastExtension/Info.plist +++ /dev/null @@ -1,38 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Voitrans 字幕悬浮窗 - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - NSExtension - - NSExtensionPointIdentifier - com.apple.broadcast-services-upload - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).SampleHandler - RPBroadcastProcessMode - RPBroadcastProcessModeSampleBuffer - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - - diff --git a/ios/BroadcastExtension/SampleHandler.swift b/ios/BroadcastExtension/SampleHandler.swift deleted file mode 100644 index d2748b12f..000000000 --- a/ios/BroadcastExtension/SampleHandler.swift +++ /dev/null @@ -1,216 +0,0 @@ -import ReplayKit -import UIKit -import AVFoundation - -/// Broadcast Upload Extension 的核心处理类 -/// 负责接收屏幕内容并叠加字幕悬浮窗 -class SampleHandler: RPBroadcastSampleHandler { - - // MARK: - 属性 - private var overlayWindow: UIWindow? - private var subtitleLabel: UILabel? - private var containerView: UIView? - private let appGroupIdentifier = "group.com.saitong.voitrans.shared" - - // MARK: - 生命周期方法 - - override func broadcastStarted(withSetupInfo setupInfo: [String : NSObject]?) { - print("🎬 ReplayKit 广播开始") - setupOverlayWindow() - startMonitoringSubtitleUpdates() - } - - override func broadcastPaused() { - print("⏸️ ReplayKit 广播暂停") - hideOverlay() - } - - override func broadcastResumed() { - print("▶️ ReplayKit 广播恢复") - showOverlay() - } - - override func broadcastFinished() { - print("🛑 ReplayKit 广播结束") - cleanupOverlay() - } - - // MARK: - 样本处理方法 - - override func processSampleBuffer(_ sampleBuffer: CMSampleBuffer, with sampleBufferType: RPSampleBufferType) { - switch sampleBufferType { - case RPSampleBufferType.video: - // 处理视频帧 - 这里我们主要用于触发悬浮窗显示 - break - case RPSampleBufferType.audioApp: - // 处理应用音频 - break - case RPSampleBufferType.audioMic: - // 处理麦克风音频 - break - @unknown default: - break - } - } - - // MARK: - 悬浮窗设置 - - /// 设置悬浮窗口 - private func setupOverlayWindow() { - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - - // 创建悬浮窗口 - self.overlayWindow = UIWindow(frame: UIScreen.main.bounds) - self.overlayWindow?.windowLevel = UIWindow.Level.alert + 1000 - self.overlayWindow?.backgroundColor = UIColor.clear - self.overlayWindow?.isHidden = false - - // 创建容器视图 - self.containerView = UIView() - self.containerView?.backgroundColor = UIColor.black.withAlphaComponent(0.8) - self.containerView?.layer.cornerRadius = 12 - self.containerView?.layer.masksToBounds = true - self.containerView?.translatesAutoresizingMaskIntoConstraints = false - - // 创建字幕标签 - self.subtitleLabel = UILabel() - self.subtitleLabel?.textColor = .white - self.subtitleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) - self.subtitleLabel?.numberOfLines = 0 - self.subtitleLabel?.textAlignment = .center - self.subtitleLabel?.translatesAutoresizingMaskIntoConstraints = false - self.subtitleLabel?.text = "等待翻译内容..." - - // 添加视图层次 - guard let containerView = self.containerView, - let subtitleLabel = self.subtitleLabel, - let overlayWindow = self.overlayWindow else { return } - - containerView.addSubview(subtitleLabel) - overlayWindow.addSubview(containerView) - - // 设置约束 - NSLayoutConstraint.activate([ - // 容器视图约束 - 位于屏幕顶部 - containerView.topAnchor.constraint(equalTo: overlayWindow.safeAreaLayoutGuide.topAnchor, constant: 20), - containerView.leadingAnchor.constraint(greaterThanOrEqualTo: overlayWindow.leadingAnchor, constant: 20), - containerView.trailingAnchor.constraint(lessThanOrEqualTo: overlayWindow.trailingAnchor, constant: -20), - containerView.centerXAnchor.constraint(equalTo: overlayWindow.centerXAnchor), - containerView.heightAnchor.constraint(greaterThanOrEqualToConstant: 60), - - // 字幕标签约束 - subtitleLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 12), - subtitleLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 16), - subtitleLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -16), - subtitleLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -12) - ]) - - print("✅ 悬浮窗口设置完成") - } - } - - /// 显示悬浮窗 - private func showOverlay() { - DispatchQueue.main.async { [weak self] in - self?.overlayWindow?.isHidden = false - self?.overlayWindow?.alpha = 1.0 - } - } - - /// 隐藏悬浮窗 - private func hideOverlay() { - DispatchQueue.main.async { [weak self] in - UIView.animate(withDuration: 0.3) { - self?.overlayWindow?.alpha = 0.0 - } completion: { _ in - self?.overlayWindow?.isHidden = true - } - } - } - - /// 清理悬浮窗 - private func cleanupOverlay() { - DispatchQueue.main.async { [weak self] in - self?.overlayWindow?.isHidden = true - self?.overlayWindow = nil - self?.subtitleLabel = nil - self?.containerView = nil - } - } - - // MARK: - 字幕更新监听 - - /// 开始监听字幕更新 - private func startMonitoringSubtitleUpdates() { - // 使用 UserDefaults 监听字幕更新 - if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) { - // 设置初始值监听 - userDefaults.addObserver(self, forKeyPath: "subtitle_text", options: [.new], context: nil) - userDefaults.addObserver(self, forKeyPath: "subtitle_source_language", options: [.new], context: nil) - userDefaults.addObserver(self, forKeyPath: "subtitle_target_language", options: [.new], context: nil) - - print("✅ 开始监听字幕更新") - } - } - - /// KVO 监听回调 - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - if keyPath == "subtitle_text" || keyPath == "subtitle_source_language" || keyPath == "subtitle_target_language" { - updateSubtitleDisplay() - } - } - - /// 更新字幕显示 - private func updateSubtitleDisplay() { - guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { return } - - let subtitleText = userDefaults.string(forKey: "subtitle_text") ?? "" - let sourceLanguage = userDefaults.string(forKey: "subtitle_source_language") ?? "" - let targetLanguage = userDefaults.string(forKey: "subtitle_target_language") ?? "" - let isIntermediate = userDefaults.bool(forKey: "subtitle_is_intermediate") - - DispatchQueue.main.async { [weak self] in - guard let self = self, let subtitleLabel = self.subtitleLabel else { return } - - if subtitleText.isEmpty { - subtitleLabel.text = "等待翻译内容..." - subtitleLabel.alpha = 0.6 - } else { - // 构建显示文本 - var displayText = subtitleText - if !sourceLanguage.isEmpty && !targetLanguage.isEmpty { - displayText = "\(sourceLanguage) → \(targetLanguage)\n\(subtitleText)" - } - - subtitleLabel.text = displayText - subtitleLabel.alpha = isIntermediate ? 0.8 : 1.0 - - // 添加淡入动画 - UIView.animate(withDuration: 0.2) { - self.containerView?.transform = CGAffineTransform(scaleX: 1.05, y: 1.05) - } completion: { _ in - UIView.animate(withDuration: 0.2) { - self.containerView?.transform = .identity - } - } - } - - print("📝 字幕更新: \(subtitleText)") - } - } - - // MARK: - 清理 - - deinit { - // 移除 KVO 监听 - if let userDefaults = UserDefaults(suiteName: appGroupIdentifier) { - userDefaults.removeObserver(self, forKeyPath: "subtitle_text") - userDefaults.removeObserver(self, forKeyPath: "subtitle_source_language") - userDefaults.removeObserver(self, forKeyPath: "subtitle_target_language") - } - - cleanupOverlay() - print("🗑️ SampleHandler 已清理") - } -} diff --git a/ios/ReplayKit_Implementation_Summary.md b/ios/ReplayKit_Implementation_Summary.md deleted file mode 100644 index f596e0fdd..000000000 --- a/ios/ReplayKit_Implementation_Summary.md +++ /dev/null @@ -1,180 +0,0 @@ -# iOS ReplayKit 悬浮窗实现总结 - -## 🎯 实现目标 - -✅ **已完成**:使用 ReplayKit + Broadcast Upload Extension 实现 iOS 全局字幕悬浮窗功能,与 Android 版本保持功能一致。 - -## 📁 文件结构 - -### 主应用文件 -``` -ios/Runner/ -├── FloatingWindowPlugin.swift # Flutter 平台通道插件 -├── ReplayKitManager.swift # ReplayKit 管理器 -├── OverlayPermissionPlugin.swift # 权限管理插件(兼容性) -├── AppDelegate.swift # 应用委托(已更新) -└── Runner.entitlements # 主应用权限配置 -``` - -### Extension 文件 -``` -ios/BroadcastExtension/ -├── SampleHandler.swift # 广播处理器(核心实现) -├── Info.plist # Extension 配置 -└── BroadcastExtension.entitlements # Extension 权限配置 -``` - -### 配置和文档 -``` -ios/ -├── ReplayKit_Integration_Guide.md # Xcode 集成指南 -└── ReplayKit_Implementation_Summary.md # 实现总结(本文件) -``` - -## 🔧 核心技术实现 - -### 1. ReplayKit 广播控制 (`ReplayKitManager.swift`) -- **功能**:管理屏幕广播的启动、停止和状态监控 -- **关键方法**: - - `startBroadcast()`: 启动 ReplayKit 广播 - - `stopBroadcast()`: 停止广播 - - `updateSubtitle()`: 更新字幕内容 -- **数据通信**:使用 App Groups 与 Extension 共享数据 - -### 2. 广播处理器 (`SampleHandler.swift`) -- **功能**:接收屏幕内容并显示悬浮窗字幕 -- **关键特性**: - - 创建全局悬浮窗口 (`UIWindow`) - - 实时监听字幕更新 (KVO + UserDefaults) - - 自适应字幕显示和动画效果 -- **显示位置**:屏幕顶部中央,半透明黑色背景 - -### 3. Flutter 接口 (`FloatingWindowPlugin.swift`) -- **功能**:提供与 Android 一致的 Flutter 调用接口 -- **方法映射**: - - `enableFloatingWindow` → 启动 ReplayKit 广播 - - `disableFloatingWindow` → 停止广播 - - `updateContent` → 更新字幕内容 - - `setPosition` → 兼容性方法(位置固定) - -## 📱 用户体验流程 - -### 启动流程 -1. 用户在翻译界面点击"悬浮窗"按钮 -2. 系统弹出"开始直播屏幕"选择界面 -3. 用户选择 "Voitrans 字幕悬浮窗" 选项 -4. 系统开始屏幕录制,状态栏显示红色录制指示器 -5. 悬浮窗出现在屏幕顶部,显示"等待翻译内容..." - -### 使用流程 -1. 应用进行语音识别和翻译 -2. 实时更新悬浮窗中的字幕内容 -3. 支持源语言和目标语言显示 -4. 中间结果和最终结果有不同的透明度显示 -5. 用户可以在任何应用中看到翻译字幕 - -### 停止流程 -1. 用户再次点击"悬浮窗"按钮或停止翻译 -2. 应用调用停止广播方法 -3. 悬浮窗消失,录制指示器消失 - -## 🔐 权限和配置 - -### App Groups 配置 -- **标识符**:`group.com.saitong.voitrans.shared` -- **用途**:主应用与 Extension 之间的数据共享 -- **数据键**: - - `subtitle_text`: 字幕文本 - - `subtitle_source_language`: 源语言 - - `subtitle_target_language`: 目标语言 - - `subtitle_is_intermediate`: 是否为中间结果 - -### 系统权限 -- **屏幕录制权限**:用户首次使用时系统自动请求 -- **麦克风权限**:如果需要录制音频(可选) -- **摄像头权限**:ReplayKit 框架要求(实际不使用) - -## 🎨 UI 设计特点 - -### 悬浮窗样式 -- **位置**:屏幕顶部中央,距离安全区域 20pt -- **背景**:黑色半透明 (alpha: 0.8) -- **圆角**:12pt 圆角矩形 -- **字体**:系统字体 16pt,白色文字 -- **布局**:垂直布局,支持多行文本 - -### 动画效果 -- **出现动画**:淡入效果 (0.3s) -- **更新动画**:轻微缩放效果 (1.05x → 1.0x) -- **状态指示**:中间结果透明度 0.8,最终结果透明度 1.0 - -## 🔄 与 Android 版本对比 - -### 功能一致性 -| 功能 | Android | iOS (ReplayKit) | 状态 | -|------|---------|-----------------|------| -| 全局悬浮窗 | ✅ 系统悬浮窗 | ✅ ReplayKit 悬浮窗 | 一致 | -| 实时字幕更新 | ✅ | ✅ | 一致 | -| 拖拽移动 | ✅ | ❌ 位置固定 | 差异 | -| 最小化/展开 | ✅ | ❌ 固定样式 | 差异 | -| 权限管理 | ✅ 悬浮窗权限 | ✅ 录制权限 | 一致 | - -### Flutter 接口一致性 -- ✅ 所有方法签名保持一致 -- ✅ 回调事件保持一致 -- ✅ 数据模型保持一致 -- ✅ 错误处理保持一致 - -## 🚀 部署和发布 - -### 开发环境要求 -- Xcode 14.0+ -- iOS 16.0+ (ReplayKit 2 要求) -- 有效的 Apple Developer 账号 -- App Groups 权限配置 - -### App Store 审核要点 -- ✅ 使用官方 ReplayKit API,符合审核规范 -- ✅ 明确的用户权限请求和说明 -- ✅ 合理的功能用途(翻译字幕显示) -- ✅ 不涉及隐私数据收集 - -### 发布注意事项 -1. 确保 Extension 的 Bundle ID 正确配置 -2. App Groups 在 Developer Portal 中正确设置 -3. 两个 target 使用相同的签名证书 -4. 测试在不同 iOS 版本上的兼容性 - -## 📊 技术优势 - -### 相比应用内悬浮窗 -- ✅ 真正的全局显示能力 -- ✅ 符合 iOS 平台规范 -- ✅ 通过 App Store 审核 -- ✅ 系统级权限管理 - -### 相比其他方案 -- ✅ 无需越狱或私有 API -- ✅ 稳定可靠的系统支持 -- ✅ 良好的用户体验 -- ✅ 与现有架构无缝集成 - -## 🔮 未来扩展 - -### 可能的改进 -1. **自定义样式**:支持更多字幕样式选项 -2. **位置调整**:探索在录制流中实现位置调整 -3. **多语言支持**:优化多语言字幕显示 -4. **性能优化**:减少内存占用和电池消耗 - -### 技术演进 -- 关注 iOS 新版本的 ReplayKit 功能更新 -- 考虑集成 Live Activities (iOS 16+) 作为补充方案 -- 探索 WidgetKit 在锁屏界面的字幕显示 - ---- - -**实现状态**: ✅ 完成 -**测试状态**: ✅ 基础功能测试通过 -**文档状态**: ✅ 完整的集成和使用文档 -**发布准备**: ⏳ 需要 Xcode 项目配置 diff --git a/ios/ReplayKit_Integration_Guide.md b/ios/ReplayKit_Integration_Guide.md deleted file mode 100644 index 505d72590..000000000 --- a/ios/ReplayKit_Integration_Guide.md +++ /dev/null @@ -1,142 +0,0 @@ -# iOS ReplayKit 悬浮窗集成指南 - -## 📋 概述 - -本指南说明如何在 Xcode 中配置 ReplayKit Broadcast Upload Extension 以实现全局字幕悬浮窗功能。 - -## 🔧 Xcode 项目配置步骤 - -### 1. 添加 Broadcast Upload Extension Target - -1. 在 Xcode 中打开 `ios/Runner.xcworkspace` -2. 选择项目根节点 → 点击 "+" 添加新 Target -3. 选择 "Broadcast Upload Extension" -4. 配置信息: - - Product Name: `BroadcastExtension` - - Bundle Identifier: `com.saitong.voitrans.BroadcastExtension` - - Language: Swift - - 确保 "Include UI Extension" 未选中 - -### 2. 配置 Extension Target - -#### 2.1 替换默认文件 -- 删除自动生成的 `SampleHandler.swift` -- 将项目中的 `ios/BroadcastExtension/SampleHandler.swift` 复制到 Extension target -- 将项目中的 `ios/BroadcastExtension/Info.plist` 替换 Extension 的 Info.plist - -#### 2.2 添加 Entitlements -- 在 Extension target 的 Build Settings 中设置 Code Signing Entitlements -- 指向 `ios/BroadcastExtension/BroadcastExtension.entitlements` - -#### 2.3 配置 Build Settings -- iOS Deployment Target: 16.0 (与主应用保持一致) -- Swift Language Version: Swift 5 -- Enable Bitcode: No - -### 3. 配置 App Groups - -#### 3.1 在 Apple Developer Portal 中: -1. 创建 App Group: `group.com.saitong.voitrans.shared` -2. 将主应用和 Extension 都添加到此 App Group - -#### 3.2 在 Xcode 中: -1. 主应用 Target → Signing & Capabilities → 添加 "App Groups" capability -2. Extension Target → Signing & Capabilities → 添加 "App Groups" capability -3. 两个 target 都勾选 `group.com.saitong.voitrans.shared` - -### 4. 更新主应用配置 - -#### 4.1 添加 ReplayKit 框架 -- 主应用 Target → Build Phases → Link Binary With Libraries -- 添加 `ReplayKit.framework` - -#### 4.2 更新 Info.plist -在主应用的 Info.plist 中添加: -```xml -NSCameraUsageDescription -需要摄像头权限以支持屏幕录制功能 -NSMicrophoneUsageDescription -需要麦克风权限以支持音频录制功能 -``` - -## 📱 使用说明 - -### Flutter 调用方式 - -```dart -// 启动悬浮窗(会弹出系统录屏选择界面) -await floatingWindowManager.enableFloatingWindow(); - -// 更新字幕内容 -await floatingWindowManager.updateFloatingWindowContent( - FloatingWindowData( - sourceText: '你好', - translatedText: 'Hello', - sourceLanguage: '中文', - targetLanguage: '英语', - timestamp: DateTime.now(), - isIntermediate: false, - ), -); - -// 停止悬浮窗 -await floatingWindowManager.disableFloatingWindow(); -``` - -### 用户操作流程 - -1. 用户点击"启用悬浮窗"按钮 -2. 系统弹出"开始直播屏幕"选择界面 -3. 用户选择 "Voitrans 字幕悬浮窗" 选项 -4. 系统开始屏幕录制,悬浮窗显示在屏幕顶部 -5. 应用实时更新字幕内容 -6. 用户可以在任何应用中看到翻译字幕 - -## ⚠️ 注意事项 - -### 开发阶段 -- 确保两个 target 使用相同的开发者账号签名 -- App Groups 必须在 Apple Developer Portal 中正确配置 -- Extension 的 Bundle ID 必须是主应用的子域名 - -### 发布阶段 -- Extension 会随主应用一起打包发布 -- 用户首次使用时需要授权屏幕录制权限 -- 符合 App Store 审核规范,因为使用的是官方 ReplayKit API - -### 用户体验 -- 录制期间状态栏会显示红色录制指示器 -- 悬浮窗位置固定在屏幕顶部中央 -- 支持实时字幕更新和语言切换 -- 可以通过停止录制来关闭悬浮窗 - -## 🔍 故障排除 - -### 常见问题 - -1. **Extension 无法启动** - - 检查 Bundle ID 配置 - - 确认 App Groups 权限配置正确 - - 验证代码签名设置 - -2. **字幕不更新** - - 检查 App Groups 数据同步 - - 确认 UserDefaults 键名一致 - - 查看 Extension 日志输出 - -3. **编译错误** - - 确保 iOS Deployment Target 一致 - - 检查 Swift 版本设置 - - 验证框架依赖配置 - -### 调试方法 - -1. 使用 Xcode 的 Device Console 查看 Extension 日志 -2. 在 SampleHandler 中添加 print 语句进行调试 -3. 检查 App Groups 中的数据是否正确写入 - -## 📚 相关文档 - -- [Apple ReplayKit Documentation](https://developer.apple.com/documentation/replaykit) -- [Broadcast Upload Extension Guide](https://developer.apple.com/documentation/replaykit/rpbroadcastsamplehandler) -- [App Groups Documentation](https://developer.apple.com/documentation/bundleresources/entitlements/com_apple_security_application-groups) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 7eff33866..e04182a8a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -8,16 +8,6 @@ import UIKit didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) - - // 注册悬浮窗相关插件 - if let registrar = self.registrar(forPlugin: "FloatingWindowPlugin") { - FloatingWindowPlugin.register(with: registrar) - } - - if let registrar = self.registrar(forPlugin: "OverlayPermissionPlugin") { - OverlayPermissionPlugin.register(with: registrar) - } - return super.application(application, didFinishLaunchingWithOptions: launchOptions) } diff --git a/ios/Runner/FloatingWindow.swift b/ios/Runner/FloatingWindow.swift deleted file mode 100644 index a60108a24..000000000 --- a/ios/Runner/FloatingWindow.swift +++ /dev/null @@ -1,341 +0,0 @@ -import UIKit - -/// 悬浮窗实现类 -/// 使用 UIWindow 创建应用内悬浮窗口 -class FloatingWindow: UIWindow { - - // MARK: - 属性 - private weak var delegate: FloatingWindowDelegate? - private var floatingViewController: FloatingViewController? - private var isMinimized = true - private var lastPosition = CGPoint(x: 100, y: 100) - - // MARK: - 初始化 - init(delegate: FloatingWindowDelegate) { - self.delegate = delegate - - // 创建窗口 - super.init(frame: CGRect(x: 100, y: 100, width: 300, height: 120)) - - setupWindow() - setupViewController() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - // MARK: - 私有方法 - - /// 设置窗口属性 - private func setupWindow() { - // 设置窗口层级,确保在最顶层显示 - windowLevel = UIWindow.Level.alert + 1 - backgroundColor = UIColor.clear - - // 设置窗口为可见但不成为主窗口 - isHidden = true - - // 设置圆角 - layer.cornerRadius = 12 - layer.masksToBounds = true - } - - /// 设置视图控制器 - private func setupViewController() { - floatingViewController = FloatingViewController() - floatingViewController?.delegate = self - rootViewController = floatingViewController - } - - // MARK: - 公共方法 - - /// 显示悬浮窗 - func show() { - isHidden = false - makeKeyAndVisible() - - // 添加显示动画 - alpha = 0 - UIView.animate(withDuration: 0.3) { - self.alpha = 1 - } - - delegate?.floatingWindowDidChangeState(isMinimized ? "minimized" : "expanded") - } - - /// 隐藏悬浮窗 - func hide() { - UIView.animate(withDuration: 0.3, animations: { - self.alpha = 0 - }) { _ in - self.isHidden = true - self.resignKey() - } - - delegate?.floatingWindowDidChangeState("hidden") - } - - /// 更新内容 - func updateContent(_ data: FloatingWindowContentData) { - floatingViewController?.updateContent(data) - } - - /// 设置位置 - func setPosition(x: Double, y: Double) { - let newPosition = CGPoint(x: x, y: y) - lastPosition = newPosition - - UIView.animate(withDuration: 0.3) { - self.frame.origin = newPosition - } - } - - /// 切换最小化/展开状态 - func toggleMinimized() { - isMinimized.toggle() - - let newSize: CGSize - if isMinimized { - newSize = CGSize(width: 120, height: 60) - } else { - newSize = CGSize(width: 300, height: 120) - } - - UIView.animate(withDuration: 0.3) { - self.frame.size = newSize - } - - floatingViewController?.setMinimized(isMinimized) - delegate?.floatingWindowDidChangeState(isMinimized ? "minimized" : "expanded") - } -} - -// MARK: - FloatingViewControllerDelegate -extension FloatingWindow: FloatingViewControllerDelegate { - - func floatingViewControllerDidTap() { - toggleMinimized() - delegate?.floatingWindowDidClick() - } - - func floatingViewControllerDidPan(translation: CGPoint) { - let newOrigin = CGPoint( - x: lastPosition.x + translation.x, - y: lastPosition.y + translation.y - ) - - // 限制在屏幕范围内 - let screenBounds = UIScreen.main.bounds - let constrainedX = max(0, min(newOrigin.x, screenBounds.width - frame.width)) - let constrainedY = max(0, min(newOrigin.y, screenBounds.height - frame.height)) - - frame.origin = CGPoint(x: constrainedX, y: constrainedY) - } - - func floatingViewControllerDidEndPan() { - lastPosition = frame.origin - } - - func floatingViewControllerDidRequestClose() { - hide() - delegate?.floatingWindowDidClose() - } -} - -/// 悬浮窗视图控制器 -private class FloatingViewController: UIViewController { - - // MARK: - 属性 - weak var delegate: FloatingViewControllerDelegate? - - // UI 组件 - private let containerView = UIView() - private let sourceTextLabel = UILabel() - private let translatedTextLabel = UILabel() - private let languageInfoLabel = UILabel() - private let timestampLabel = UILabel() - private let closeButton = UIButton(type: .system) - - // 手势识别器 - private var panGestureRecognizer: UIPanGestureRecognizer! - private var tapGestureRecognizer: UITapGestureRecognizer! - - private var isMinimized = true - - // MARK: - 生命周期 - override func viewDidLoad() { - super.viewDidLoad() - setupUI() - setupGestures() - } - - // MARK: - 私有方法 - - /// 设置 UI - private func setupUI() { - view.backgroundColor = UIColor.clear - - // 容器视图 - containerView.backgroundColor = UIColor.black.withAlphaComponent(0.8) - containerView.layer.cornerRadius = 12 - containerView.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(containerView) - - // 源文本标签 - sourceTextLabel.textColor = .white - sourceTextLabel.font = UIFont.systemFont(ofSize: 14) - sourceTextLabel.numberOfLines = 2 - sourceTextLabel.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(sourceTextLabel) - - // 翻译文本标签 - translatedTextLabel.textColor = .systemBlue - translatedTextLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) - translatedTextLabel.numberOfLines = 2 - translatedTextLabel.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(translatedTextLabel) - - // 语言信息标签 - languageInfoLabel.textColor = .lightGray - languageInfoLabel.font = UIFont.systemFont(ofSize: 10) - languageInfoLabel.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(languageInfoLabel) - - // 时间戳标签 - timestampLabel.textColor = .lightGray - timestampLabel.font = UIFont.systemFont(ofSize: 10) - timestampLabel.textAlignment = .right - timestampLabel.translatesAutoresizingMaskIntoConstraints = false - containerView.addSubview(timestampLabel) - - // 关闭按钮 - closeButton.setTitle("×", for: .normal) - closeButton.setTitleColor(.white, for: .normal) - closeButton.titleLabel?.font = UIFont.systemFont(ofSize: 18, weight: .bold) - closeButton.translatesAutoresizingMaskIntoConstraints = false - closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside) - containerView.addSubview(closeButton) - - setupConstraints() - } - - /// 设置约束 - private func setupConstraints() { - NSLayoutConstraint.activate([ - // 容器视图 - containerView.topAnchor.constraint(equalTo: view.topAnchor), - containerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - containerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - // 关闭按钮 - closeButton.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), - closeButton.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -4), - closeButton.widthAnchor.constraint(equalToConstant: 20), - closeButton.heightAnchor.constraint(equalToConstant: 20), - - // 源文本标签 - sourceTextLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 8), - sourceTextLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), - sourceTextLabel.trailingAnchor.constraint(equalTo: closeButton.leadingAnchor, constant: -4), - - // 翻译文本标签 - translatedTextLabel.topAnchor.constraint(equalTo: sourceTextLabel.bottomAnchor, constant: 4), - translatedTextLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), - translatedTextLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), - - // 语言信息标签 - languageInfoLabel.topAnchor.constraint(equalTo: translatedTextLabel.bottomAnchor, constant: 4), - languageInfoLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), - languageInfoLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4), - - // 时间戳标签 - timestampLabel.topAnchor.constraint(equalTo: translatedTextLabel.bottomAnchor, constant: 4), - timestampLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), - timestampLabel.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -4) - ]) - } - - /// 设置手势识别器 - private func setupGestures() { - // 拖拽手势 - panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) - view.addGestureRecognizer(panGestureRecognizer) - - // 点击手势 - tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) - view.addGestureRecognizer(tapGestureRecognizer) - } - - // MARK: - 手势处理 - - @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: view.superview) - - switch gesture.state { - case .changed: - delegate?.floatingViewControllerDidPan(translation: translation) - case .ended, .cancelled: - delegate?.floatingViewControllerDidEndPan() - default: - break - } - } - - @objc private func handleTap(_ gesture: UITapGestureRecognizer) { - delegate?.floatingViewControllerDidTap() - } - - @objc private func closeButtonTapped() { - delegate?.floatingViewControllerDidRequestClose() - } - - // MARK: - 公共方法 - - /// 更新内容 - func updateContent(_ data: FloatingWindowContentData) { - sourceTextLabel.text = data.sourceText.isEmpty ? "正在识别..." : data.sourceText - translatedTextLabel.text = data.translatedText.isEmpty ? "翻译中..." : data.translatedText - languageInfoLabel.text = "\(data.sourceLanguage) → \(data.targetLanguage)" - - // 格式化时间戳 - if data.timestamp > 0 { - let date = Date(timeIntervalSince1970: TimeInterval(data.timestamp / 1000)) - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - timestampLabel.text = formatter.string(from: date) - } - - // 中间结果的视觉提示 - translatedTextLabel.alpha = data.isIntermediate ? 0.7 : 1.0 - } - - /// 设置最小化状态 - func setMinimized(_ minimized: Bool) { - isMinimized = minimized - - // 在最小化状态下隐藏部分元素 - sourceTextLabel.isHidden = minimized - languageInfoLabel.isHidden = minimized - timestampLabel.isHidden = minimized - closeButton.isHidden = minimized - - // 调整翻译文本标签的约束 - if minimized { - translatedTextLabel.numberOfLines = 1 - translatedTextLabel.font = UIFont.systemFont(ofSize: 12, weight: .medium) - } else { - translatedTextLabel.numberOfLines = 2 - translatedTextLabel.font = UIFont.systemFont(ofSize: 14, weight: .medium) - } - } -} - -/// 悬浮窗视图控制器代理协议 -private protocol FloatingViewControllerDelegate: AnyObject { - func floatingViewControllerDidTap() - func floatingViewControllerDidPan(translation: CGPoint) - func floatingViewControllerDidEndPan() - func floatingViewControllerDidRequestClose() -} diff --git a/ios/Runner/FloatingWindowPlugin.swift b/ios/Runner/FloatingWindowPlugin.swift deleted file mode 100644 index 76d71222d..000000000 --- a/ios/Runner/FloatingWindowPlugin.swift +++ /dev/null @@ -1,154 +0,0 @@ -import Flutter -import UIKit - -/// iOS 悬浮窗插件 - ReplayKit 方案 -/// 使用 ReplayKit + Broadcast Upload Extension 实现全局字幕悬浮窗 -public class FloatingWindowPlugin: NSObject, FlutterPlugin { - - // MARK: - 常量 - private static let channelName = "floating_window" - - // MARK: - 属性 - private var methodChannel: FlutterMethodChannel? - private let replayKitManager = ReplayKitManager.shared - - // MARK: - 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = FloatingWindowPlugin() - - // 创建方法通道 - let methodChannel = FlutterMethodChannel( - name: channelName, - binaryMessenger: registrar.messenger() - ) - instance.methodChannel = methodChannel - registrar.addMethodCallDelegate(instance, channel: methodChannel) - - // 设置 ReplayKit 回调 - instance.setupReplayKitCallbacks() - - print("FloatingWindowPlugin (ReplayKit) 注册成功") - } - - // MARK: - 方法调用处理 - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "enableFloatingWindow": - enableFloatingWindow(result: result) - - case "disableFloatingWindow": - disableFloatingWindow(result: result) - - case "showFloatingWindow": - showFloatingWindow(result: result) - - case "hideFloatingWindow": - hideFloatingWindow(result: result) - - case "updateContent": - if let arguments = call.arguments as? [String: Any] { - updateContent(arguments: arguments, result: result) - } else { - result(FlutterError(code: "INVALID_ARGUMENTS", message: "无效的参数", details: nil)) - } - - case "setPosition": - // ReplayKit 方案中位置固定,但保持接口兼容性 - result(true) - - default: - result(FlutterMethodNotImplemented) - } - } - - // MARK: - ReplayKit 回调设置 - - /// 设置 ReplayKit 回调 - private func setupReplayKitCallbacks() { - replayKitManager.onBroadcastStarted = { [weak self] in - self?.methodChannel?.invokeMethod("onFloatingWindowStateChanged", arguments: ["state": "minimized"]) - } - - replayKitManager.onBroadcastFinished = { [weak self] in - self?.methodChannel?.invokeMethod("onFloatingWindowClosed", arguments: nil) - } - - replayKitManager.onBroadcastError = { [weak self] error in - self?.methodChannel?.invokeMethod("onFloatingWindowError", arguments: ["error": error.localizedDescription]) - } - } - - // MARK: - 悬浮窗控制方法 - - /// 启用悬浮窗功能(启动 ReplayKit 广播) - private func enableFloatingWindow(result: @escaping FlutterResult) { - replayKitManager.startBroadcast { success, error in - if success { - result(true) - } else { - result(FlutterError( - code: "ENABLE_ERROR", - message: "启用悬浮窗失败: \(error?.localizedDescription ?? "未知错误")", - details: nil - )) - } - } - } - - /// 禁用悬浮窗功能(停止 ReplayKit 广播) - private func disableFloatingWindow(result: @escaping FlutterResult) { - replayKitManager.stopBroadcast { success, error in - if success { - result(true) - } else { - result(FlutterError( - code: "DISABLE_ERROR", - message: "禁用悬浮窗失败: \(error?.localizedDescription ?? "未知错误")", - details: nil - )) - } - } - } - - /// 显示悬浮窗(ReplayKit 方案中自动显示) - private func showFloatingWindow(result: @escaping FlutterResult) { - if replayKitManager.isBroadcasting { - result(true) - } else { - result(FlutterError(code: "NOT_BROADCASTING", message: "广播未启动", details: nil)) - } - } - - /// 隐藏悬浮窗(ReplayKit 方案中通过停止广播实现) - private func hideFloatingWindow(result: @escaping FlutterResult) { - disableFloatingWindow(result: result) - } - - /// 更新悬浮窗内容 - private func updateContent(arguments: [String: Any], result: @escaping FlutterResult) { - let sourceText = arguments["sourceText"] as? String ?? "" - let translatedText = arguments["translatedText"] as? String ?? "" - let sourceLanguage = arguments["sourceLanguage"] as? String ?? "" - let targetLanguage = arguments["targetLanguage"] as? String ?? "" - let isIntermediate = arguments["isIntermediate"] as? Bool ?? false - - // 构建显示文本 - var displayText = "" - if !sourceText.isEmpty && !translatedText.isEmpty { - displayText = "\(sourceText)\n\(translatedText)" - } else if !translatedText.isEmpty { - displayText = translatedText - } else if !sourceText.isEmpty { - displayText = sourceText - } - - replayKitManager.updateSubtitle( - text: displayText, - sourceLanguage: sourceLanguage, - targetLanguage: targetLanguage, - isIntermediate: isIntermediate - ) - - result(true) - } - diff --git a/ios/Runner/OverlayPermissionPlugin.swift b/ios/Runner/OverlayPermissionPlugin.swift deleted file mode 100644 index fdabc261f..000000000 --- a/ios/Runner/OverlayPermissionPlugin.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Flutter -import UIKit - -/// iOS 悬浮窗权限插件 -/// 为了与 Android 版本保持接口一致性而创建 -public class OverlayPermissionPlugin: NSObject, FlutterPlugin { - - // MARK: - 常量 - private static let channelName = "overlay_permission" - - // MARK: - 插件注册 - public static func register(with registrar: FlutterPluginRegistrar) { - let instance = OverlayPermissionPlugin() - - // 创建方法通道 - let methodChannel = FlutterMethodChannel( - name: channelName, - binaryMessenger: registrar.messenger() - ) - registrar.addMethodCallDelegate(instance, channel: methodChannel) - - print("OverlayPermissionPlugin 注册成功") - } - - // MARK: - 方法调用处理 - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "hasOverlayPermission": - // iOS 使用应用内悬浮窗,不需要特殊权限 - result(true) - - case "requestOverlayPermission": - // iOS 使用应用内悬浮窗,不需要特殊权限 - result(true) - - default: - result(FlutterMethodNotImplemented) - } - } -} diff --git a/ios/Runner/ReplayKitManager.swift b/ios/Runner/ReplayKitManager.swift deleted file mode 100644 index 7588ca8e1..000000000 --- a/ios/Runner/ReplayKitManager.swift +++ /dev/null @@ -1,247 +0,0 @@ -import ReplayKit -import UIKit - -/// ReplayKit 管理器 -/// 负责管理屏幕广播的启动、停止和状态监控 -class ReplayKitManager: NSObject { - - // MARK: - 单例 - static let shared = ReplayKitManager() - - // MARK: - 属性 - private var broadcastController: RPBroadcastController? - private let appGroupIdentifier = "group.com.saitong.voitrans.shared" - private var isRecording = false - - // 回调闭包 - var onBroadcastStarted: (() -> Void)? - var onBroadcastFinished: (() -> Void)? - var onBroadcastError: ((Error) -> Void)? - - // MARK: - 初始化 - private override init() { - super.init() - setupNotifications() - } - - // MARK: - 公共方法 - - /// 启动屏幕广播 - /// - Parameter completion: 完成回调 - func startBroadcast(completion: @escaping (Bool, Error?) -> Void) { - print("🎬 准备启动 ReplayKit 广播") - - // 检查是否已经在录制 - if isRecording { - print("⚠️ 广播已在进行中") - completion(false, NSError(domain: "ReplayKitManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "广播已在进行中"])) - return - } - - // 获取广播选择器 - RPBroadcastActivityViewController.load { [weak self] (broadcastActivityViewController, error) in - DispatchQueue.main.async { - guard let self = self else { return } - - if let error = error { - print("❌ 加载广播选择器失败: \(error.localizedDescription)") - completion(false, error) - return - } - - guard let broadcastActivityViewController = broadcastActivityViewController else { - let error = NSError(domain: "ReplayKitManager", code: -2, userInfo: [NSLocalizedDescriptionKey: "无法加载广播选择器"]) - completion(false, error) - return - } - - // 设置代理 - broadcastActivityViewController.delegate = self - - // 显示广播选择器 - if let topViewController = self.getTopViewController() { - broadcastActivityViewController.modalPresentationStyle = .formSheet - topViewController.present(broadcastActivityViewController, animated: true) { - print("✅ 广播选择器已显示") - completion(true, nil) - } - } else { - let error = NSError(domain: "ReplayKitManager", code: -3, userInfo: [NSLocalizedDescriptionKey: "无法获取顶层视图控制器"]) - completion(false, error) - } - } - } - } - - /// 停止屏幕广播 - /// - Parameter completion: 完成回调 - func stopBroadcast(completion: @escaping (Bool, Error?) -> Void) { - print("🛑 准备停止 ReplayKit 广播") - - guard isRecording else { - print("⚠️ 当前没有进行中的广播") - completion(false, NSError(domain: "ReplayKitManager", code: -4, userInfo: [NSLocalizedDescriptionKey: "当前没有进行中的广播"])) - return - } - - broadcastController?.finishBroadcast { [weak self] error in - DispatchQueue.main.async { - if let error = error { - print("❌ 停止广播失败: \(error.localizedDescription)") - completion(false, error) - } else { - print("✅ 广播已停止") - self?.isRecording = false - self?.broadcastController = nil - completion(true, nil) - } - } - } - } - - /// 更新字幕内容 - /// - Parameters: - /// - text: 字幕文本 - /// - sourceLanguage: 源语言 - /// - targetLanguage: 目标语言 - /// - isIntermediate: 是否为中间结果 - func updateSubtitle(text: String, sourceLanguage: String = "", targetLanguage: String = "", isIntermediate: Bool = false) { - guard let userDefaults = UserDefaults(suiteName: appGroupIdentifier) else { - print("❌ 无法访问 App Group UserDefaults") - return - } - - // 更新字幕数据 - userDefaults.set(text, forKey: "subtitle_text") - userDefaults.set(sourceLanguage, forKey: "subtitle_source_language") - userDefaults.set(targetLanguage, forKey: "subtitle_target_language") - userDefaults.set(isIntermediate, forKey: "subtitle_is_intermediate") - userDefaults.set(Date().timeIntervalSince1970, forKey: "subtitle_timestamp") - - // 强制同步 - userDefaults.synchronize() - - print("📝 字幕已更新: \(text)") - } - - /// 检查广播状态 - var isBroadcasting: Bool { - return isRecording - } - - // MARK: - 私有方法 - - /// 设置通知监听 - private func setupNotifications() { - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(applicationWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - } - - /// 应用进入后台 - @objc private func applicationDidEnterBackground() { - print("📱 应用进入后台,广播状态: \(isRecording)") - } - - /// 应用即将进入前台 - @objc private func applicationWillEnterForeground() { - print("📱 应用即将进入前台,广播状态: \(isRecording)") - } - - /// 获取顶层视图控制器 - private func getTopViewController() -> UIViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { - return nil - } - - var topViewController = window.rootViewController - while let presentedViewController = topViewController?.presentedViewController { - topViewController = presentedViewController - } - - return topViewController - } - - // MARK: - 清理 - deinit { - NotificationCenter.default.removeObserver(self) - print("🗑️ ReplayKitManager 已清理") - } -} - -// MARK: - RPBroadcastActivityViewControllerDelegate -extension ReplayKitManager: RPBroadcastActivityViewControllerDelegate { - - func broadcastActivityViewController(_ broadcastActivityViewController: RPBroadcastActivityViewController, didFinishWith broadcastController: RPBroadcastController?, error: Error?) { - - DispatchQueue.main.async { [weak self] in - // 关闭选择器 - broadcastActivityViewController.dismiss(animated: true) - - if let error = error { - print("❌ 广播设置失败: \(error.localizedDescription)") - self?.onBroadcastError?(error) - return - } - - guard let broadcastController = broadcastController else { - print("❌ 无法获取广播控制器") - let error = NSError(domain: "ReplayKitManager", code: -5, userInfo: [NSLocalizedDescriptionKey: "无法获取广播控制器"]) - self?.onBroadcastError?(error) - return - } - - // 保存广播控制器 - self?.broadcastController = broadcastController - broadcastController.delegate = self - - // 开始广播 - broadcastController.startBroadcast { error in - DispatchQueue.main.async { - if let error = error { - print("❌ 开始广播失败: \(error.localizedDescription)") - self?.onBroadcastError?(error) - } else { - print("✅ 广播已开始") - self?.isRecording = true - self?.onBroadcastStarted?() - } - } - } - } - } -} - -// MARK: - RPBroadcastControllerDelegate -extension ReplayKitManager: RPBroadcastControllerDelegate { - - func broadcastController(_ broadcastController: RPBroadcastController, didFinishWithError error: Error?) { - DispatchQueue.main.async { [weak self] in - self?.isRecording = false - self?.broadcastController = nil - - if let error = error { - print("❌ 广播结束时发生错误: \(error.localizedDescription)") - self?.onBroadcastError?(error) - } else { - print("✅ 广播正常结束") - self?.onBroadcastFinished?() - } - } - } - - func broadcastController(_ broadcastController: RPBroadcastController, didUpdateServiceInfo serviceInfo: [String : NSCoding & NSObjectProtocol]) { - print("📊 广播服务信息更新: \(serviceInfo)") - } -} diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index b576c2e34..f05d7901a 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -30,9 +30,5 @@ com.apple.security.personal-information.photos-library - com.apple.security.application-groups - - group.com.saitong.voitrans.shared - diff --git a/lib/core/utils/overlay_permission_util.dart b/lib/core/utils/overlay_permission_util.dart index 7eeebc7b3..6a580621c 100644 --- a/lib/core/utils/overlay_permission_util.dart +++ b/lib/core/utils/overlay_permission_util.dart @@ -19,13 +19,9 @@ class OverlayPermissionUtil { Logger.error('检查悬浮窗权限失败: $e'); return false; } - } else if (Platform.isIOS) { - // iOS 使用应用内悬浮窗,不需要特殊权限 - Logger.info('iOS 平台:使用应用内悬浮窗,无需特殊权限'); - return true; } - // 其他平台不支持 - return false; + // iOS不需要特殊权限 + return true; } /// 请求悬浮窗权限 @@ -68,12 +64,7 @@ class OverlayPermissionUtil { } } return false; - } else if (Platform.isIOS) { - // iOS 使用应用内悬浮窗,不需要特殊权限,但需要检查应用状态 - Logger.info('iOS 平台:使用应用内悬浮窗,无需特殊权限'); - return true; } - // 其他平台不支持 - return false; + return true; } } diff --git a/test/ios_floating_window_test.dart b/test/ios_floating_window_test.dart deleted file mode 100644 index 2c274e42e..000000000 --- a/test/ios_floating_window_test.dart +++ /dev/null @@ -1,149 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter/services.dart'; -import 'package:deep_voice/core/services/floating_window_manager.dart'; - -/// iOS ReplayKit 悬浮窗功能测试 -void main() { - group('iOS ReplayKit 悬浮窗测试', () { - late FloatingWindowManager floatingWindowManager; - late List methodCalls; - - setUp(() { - methodCalls = []; - floatingWindowManager = FloatingWindowManager(); - - // 模拟 iOS 平台的方法通道调用 - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('floating_window'), - (MethodCall methodCall) async { - methodCalls.add(methodCall); - - switch (methodCall.method) { - case 'enableFloatingWindow': - return true; - case 'disableFloatingWindow': - return true; - case 'showFloatingWindow': - return true; - case 'hideFloatingWindow': - return true; - case 'updateContent': - return true; - case 'setPosition': - return true; - default: - return null; - } - }, - ); - }); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler( - const MethodChannel('floating_window'), - null, - ); - }); - - test('启用悬浮窗功能 (ReplayKit)', () async { - final result = await floatingWindowManager.enableFloatingWindow(); - - expect(result, isTrue); - expect(methodCalls.length, equals(1)); - expect(methodCalls[0].method, equals('enableFloatingWindow')); - }); - - test('禁用悬浮窗功能', () async { - await floatingWindowManager.disableFloatingWindow(); - - expect(methodCalls.length, equals(1)); - expect(methodCalls[0].method, equals('disableFloatingWindow')); - }); - - test('显示悬浮窗', () async { - await floatingWindowManager.showFloatingWindow(); - - expect(methodCalls.length, equals(1)); - expect(methodCalls[0].method, equals('showFloatingWindow')); - }); - - test('隐藏悬浮窗', () async { - await floatingWindowManager.hideFloatingWindow(); - - expect(methodCalls.length, equals(1)); - expect(methodCalls[0].method, equals('hideFloatingWindow')); - }); - - test('更新悬浮窗内容', () async { - final testData = FloatingWindowData( - sourceText: '你好', - translatedText: 'Hello', - sourceLanguage: '中文', - targetLanguage: '英语', - timestamp: DateTime.now(), - isIntermediate: false, - ); - - await floatingWindowManager.updateFloatingWindowContent(testData); - - expect(methodCalls.length, equals(1)); - expect(methodCalls[0].method, equals('updateContent')); - - final arguments = methodCalls[0].arguments as Map; - expect(arguments['sourceText'], equals('你好')); - expect(arguments['translatedText'], equals('Hello')); - expect(arguments['sourceLanguage'], equals('中文')); - expect(arguments['targetLanguage'], equals('英语')); - expect(arguments['isIntermediate'], equals(false)); - }); - - test('设置悬浮窗位置', () async { - await floatingWindowManager.setFloatingWindowPosition(100.0, 200.0); - - expect(methodCalls.length, equals(1)); - expect(methodCalls[0].method, equals('setPosition')); - - final arguments = methodCalls[0].arguments as Map; - expect(arguments['x'], equals(100.0)); - expect(arguments['y'], equals(200.0)); - }); - - test('悬浮窗状态管理', () async { - // 初始状态 - expect(floatingWindowManager.isEnabled, isFalse); - expect(floatingWindowManager.currentState, equals(FloatingWindowState.hidden)); - - // 启用悬浮窗 - await floatingWindowManager.enableFloatingWindow(); - expect(floatingWindowManager.isEnabled, isTrue); - - // 显示悬浮窗 - await floatingWindowManager.showFloatingWindow(); - // 注意:实际状态变化需要通过原生回调触发,这里只测试方法调用 - }); - }); - - group('悬浮窗数据模型测试', () { - test('FloatingWindowData 序列化', () { - final data = FloatingWindowData( - sourceText: '测试文本', - translatedText: 'Test Text', - sourceLanguage: '中文(简体)', - targetLanguage: '英语', - timestamp: DateTime(2024, 1, 1, 12, 0, 0), - isIntermediate: true, - ); - - final map = data.toMap(); - - expect(map['sourceText'], equals('测试文本')); - expect(map['translatedText'], equals('Test Text')); - expect(map['sourceLanguage'], equals('中文(简体)')); - expect(map['targetLanguage'], equals('英语')); - expect(map['timestamp'], equals(DateTime(2024, 1, 1, 12, 0, 0).millisecondsSinceEpoch)); - expect(map['isIntermediate'], equals(true)); - }); - }); -} From 070ff450a100428917403d929fc22bb82cf506ee Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Thu, 9 Oct 2025 19:49:35 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(=E8=AE=BE=E5=A4=87=E6=A3=80=E6=B5=8B):?= =?UTF-8?q?=20=E6=94=B9=E8=BF=9B=E8=AE=BE=E5=A4=87=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E5=92=8C=E7=94=B5=E9=87=8F=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 扩展单端耳机检测范围,新增智能录音笔和智能眼镜类型 重构电量检查逻辑,根据设备类型采用不同策略 为单端耳机添加专门的电池显示和低电量提示 --- .../home/controllers/home_controller.dart | 4 +- lib/modules/home/views/home_view.dart | 3 +- .../controllers/ota_core_contropller.dart | 145 +++++++++++++++++- .../controllers/pairing_controller.dart | 6 +- 4 files changed, 147 insertions(+), 11 deletions(-) diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index a7b702576..7d8aa47af 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -393,7 +393,9 @@ class HomeController extends GetxController { /// 判断当前设备是否为单端耳机 bool isSingleHeadset() { - return getCurrentDeviceType() == DeviceType.bleBluetoothSingleHeadset; + return getCurrentDeviceType() == DeviceType.bleBluetoothSingleHeadset || + getCurrentDeviceType() == DeviceType.smartVoiceRecorder || + getCurrentDeviceType() == DeviceType.smartEye; } String getVersionText() { diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index e656f0b0c..fccea3b5e 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -533,7 +533,8 @@ class HomeView extends GetView { // 单端耳机只显示一个电池信息(优先显示左耳,如果左耳电量为0则显示右耳) if (isSingleHeadset) ...[ _buildCompactBatteryItem( - label: 'deviceTypeBleBluetoothHeadset'.tr, + //label: 'deviceTypeBleBluetoothHeadset'.tr, + label: '', batteryLevel: (leftBattery > 0 || rightBattery == 0) ? leftBattery : rightBattery, diff --git a/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart b/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart index 03ec1be87..cd05687f0 100644 --- a/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart +++ b/lib/modules/ota_upgrade/controllers/ota_core_contropller.dart @@ -185,22 +185,155 @@ class OtaCoreService { return false; } + // 获取设备类型以确定检查策略 + String deviceName = getDeviceName(); + DBProduct? product = AppConfig.getproduct(deviceName); + if (product == null) { + // 如果根据设备名称找不到产品,尝试使用默认产品名称 + product = AppConfig.getproduct("DeapSound"); + if (product == null) { + _showBatteryCheckFailedSnackbar('无法获取产品信息,设备名称: $deviceName'); + return false; + } + } + final leftBattery = deviceInfo.leftEarBattery; final rightBattery = deviceInfo.rightEarBattery; - // 检查左右耳电量是否都大于20% - if (leftBattery <= 20 || rightBattery <= 20) { - _showLowBatteryDialog(leftBattery, rightBattery); - return false; + // 根据设备类型进行不同的电量检查策略 + if (product.devicetype == DeviceType.bleBluetoothSingleHeadset.value || + product.devicetype == DeviceType.smartEye.value || + product.devicetype == DeviceType.smartVoiceRecorder.value) { + // 单端耳机:只需要检查有效的那一端电量 + return _checkSingleHeadsetBattery(leftBattery, rightBattery); + } else if (product.devicetype == + DeviceType.bleBluetoothDoubleHeadset.value) { + // 双端耳机:需要检查左右两端电量 + return _checkDoubleHeadsetBattery(leftBattery, rightBattery); + } else { + // 其他设备类型,使用原有逻辑(双端检查) + return _checkDoubleHeadsetBattery(leftBattery, rightBattery); } - - return true; } catch (e) { _showBatteryCheckFailedSnackbar('检查设备电量时发生错误:$e'); return false; } } + /// 检查单端耳机电量 + /// [leftBattery] 左耳电量 + /// [rightBattery] 右耳电量 + /// 返回true表示电量充足 + bool _checkSingleHeadsetBattery(int leftBattery, int rightBattery) { + // 单端耳机逻辑:优先检查左耳,如果左耳无效(<=0或127)则检查右耳 + int activeBattery; + String activeEar; + + if (leftBattery > 0 && leftBattery != 127) { + // 左耳有效,使用左耳电量 + activeBattery = leftBattery; + activeEar = '左耳'; + } else if (rightBattery > 0 && rightBattery != 127) { + // 左耳无效,使用右耳电量 + activeBattery = rightBattery; + activeEar = '右耳'; + } else { + // 两边都无效 + _showBatteryCheckFailedSnackbar('无法获取有效的电量信息'); + return false; + } + + // 检查有效端的电量是否大于20% + if (activeBattery <= 20) { + _showSingleHeadsetLowBatteryDialog(activeEar, activeBattery); + return false; + } + + return true; + } + + /// 检查双端耳机电量 + /// [leftBattery] 左耳电量 + /// [rightBattery] 右耳电量 + /// 返回true表示电量充足 + bool _checkDoubleHeadsetBattery(int leftBattery, int rightBattery) { + // 双端耳机逻辑:需要检查左右两端电量是否都大于20% + + // 检查电量数据有效性 + bool leftValid = leftBattery > 0 && leftBattery != 127; + bool rightValid = rightBattery > 0 && rightBattery != 127; + + if (!leftValid || !rightValid) { + _showBatteryCheckFailedSnackbar('无法获取有效的电量信息'); + return false; + } + + // 如果只有一端有效,按单端处理 + // if (!leftValid || !rightValid) { + // String activeEar = leftValid ? '左耳' : '右耳'; + // int activeBattery = leftValid ? leftBattery : rightBattery; + + // if (activeBattery <= 20) { + // _showSingleHeadsetLowBatteryDialog(activeEar, activeBattery); + // return false; + // } + // return true; + // } + + // 两端都有效,检查是否都大于20% + if (leftBattery <= 20 || rightBattery <= 20) { + _showLowBatteryDialog(leftBattery, rightBattery); + return false; + } + + return true; + } + + /// 显示单端耳机电量不足对话框 + /// [earSide] 耳机端(左耳/右耳) + /// [batteryLevel] 电量百分比 + void _showSingleHeadsetLowBatteryDialog(String earSide, int batteryLevel) { + String message = '设备电量不足,无法进行固件升级\n'; + message += '升级要求:设备电量需大于20%\n'; + message += '当前电量:$earSide ${batteryLevel}%\n'; + message += '请充电后再试'; + + Get.dialog( + AlertDialog( + title: Text( + '电量不足', + style: TextStyle( + color: Colors.orange[700], + fontWeight: FontWeight.w600, + ), + ), + content: Text( + message, + style: TextStyle( + color: Colors.orange[600], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + style: TextButton.styleFrom( + foregroundColor: Colors.orange[700], + ), + child: const Text('确定'), + ), + ], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Colors.orange.withValues(alpha: 0.3), + width: 1, + ), + ), + backgroundColor: Colors.orange[50], + ), + ); + } + /// 显示电量不足对话框 /// [leftBattery] 左耳电量 /// [rightBattery] 右耳电量 diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 51387abe8..3fb4e4ceb 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -817,7 +817,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { if (Platform.isAndroid) { final existingDevice = User.instance.devices.firstWhere( (d) => - d.devicetype == 2 && + d.devicetype != 1 && d.devicename.contains(deviceName) && d.devicemac == device.address, orElse: () => UserDevice( @@ -879,7 +879,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { //检查是否已经绑定 final existingDevice = User.instance.devices.firstWhere( (d) => - d.devicetype == 2 && + d.devicetype != 1 && d.license != "" && d.devicemac == device.address, orElse: () => UserDevice( @@ -913,7 +913,7 @@ class PairingController extends GetxController with WidgetsBindingObserver { //检查是否已经绑定 final existingDevice = User.instance.devices.firstWhere( (d) => - d.devicetype == 2 && + d.devicetype != 1 && d.license != "" && d.devicemac == device.address, orElse: () => UserDevice( From f2947fb15bcb519583b20f5bd25909e1e4dadfe4 Mon Sep 17 00:00:00 2001 From: lxm <1274844347@qq.com> Date: Fri, 10 Oct 2025 18:51:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(=E8=AE=BE=E5=A4=87=E4=BF=A1=E6=81=AF):?= =?UTF-8?q?=20=E6=A0=B9=E6=8D=AE=E8=AE=BE=E5=A4=87=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E6=98=BE=E7=A4=BA=E7=94=B5=E6=B1=A0=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加 DeviceType 扩展方法获取显示名称,并在单端耳机电池信息处使用该名称替代空字符串 --- lib/data/models/appconfig_model.dart | 22 ++++++++++++++++++++++ lib/modules/home/views/home_view.dart | 6 ++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/lib/data/models/appconfig_model.dart b/lib/data/models/appconfig_model.dart index 945103b42..7f7b6bd59 100644 --- a/lib/data/models/appconfig_model.dart +++ b/lib/data/models/appconfig_model.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:get/get.dart'; part 'appconfig_model.g.dart'; // 生成的文件名 @@ -136,3 +137,24 @@ enum DeviceType { /// 设备类型对应的整数值 final int value; } + +// 为 DeviceType 添加扩展方法 +extension DeviceTypeExtension on DeviceType { + /// 获取设备类型的显示名称 + /// @Description 根据设备类型返回对应的翻译键 + String get displayName { + switch (this) { + case DeviceType.unknown: + return "deviceTypeUnknown".tr; // 未知设备 + case DeviceType.classicBluetoothHeadset: + return "deviceTypeClassicBluetoothHeadset".tr; // 经典蓝牙耳机 + case DeviceType.bleBluetoothDoubleHeadset: + case DeviceType.bleBluetoothSingleHeadset: + return "deviceTypeBleBluetoothHeadset".tr; // BLE蓝牙耳机(单端和双端都使用同一个翻译键) + case DeviceType.smartVoiceRecorder: + return "deviceTypeSmartVoiceRecorder".tr; // 智能录音笔 + case DeviceType.smartEye: + return "deviceTypeSmartEye".tr; // 智能眼镜 + } + } +} diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index fccea3b5e..5e5a72be4 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -6,6 +6,7 @@ import '../../../core/widgets/intro_overlay_builder.dart'; import '../controllers/home_controller.dart'; import '../../../routes/app_routes.dart'; import '../../../core/widgets/common_bottom_nav.dart'; +import '../../../data/models/appconfig_model.dart'; // 呼吸灯动画组件 class AnimatedPulsing extends StatefulWidget { @@ -533,8 +534,9 @@ class HomeView extends GetView { // 单端耳机只显示一个电池信息(优先显示左耳,如果左耳电量为0则显示右耳) if (isSingleHeadset) ...[ _buildCompactBatteryItem( - //label: 'deviceTypeBleBluetoothHeadset'.tr, - label: '', + label: controller + .getCurrentDeviceType() + .displayName, // 根据设备类型动态显示文本 batteryLevel: (leftBattery > 0 || rightBattery == 0) ? leftBattery : rightBattery, From b01a40433f2eb7c252b57660fd2521a82b3733b8 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 10 Oct 2025 19:31:41 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=B8=8A=E4=BC=A0ai=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/data/models/message_model.dart | 23 ++ .../agent/controllers/agent_controller.dart | 197 +++++++++----- lib/modules/agent/views/agent_view.dart | 8 + lib/modules/agent/views/message_bubble.dart | 236 ++++++++++++++-- .../agent_service/AgentService.kt | 84 +++--- .../agent_service/AgentServicePlugin.kt | 12 + .../yunqiinnovation/agent_service/BleAgent.kt | 4 +- .../agent_service/AgentServiceImpl.swift | 45 ++-- .../agent_service/AgentServicePlugin.swift | 6 +- .../agent_service/lib/agent_service.dart | 7 +- .../azure_speech/AzureAsrHelper.kt | 41 +-- .../Sources/azure_speech/AzureAsrHelper.swift | 28 +- .../azure_speech/AzureSpeechPlugin.swift | 12 +- .../chat_api/ChatApiService.kt | 253 ++++++++++-------- .../chat_api/CustomSseClientTransport.kt | 16 +- .../chat_storage/ChatStorageHelper.kt | 52 ++-- .../chat_storage/ChatStoragePlugin.kt | 14 +- .../chat_storage/ChatStorageHelper.swift | 33 +-- .../chat_storage/SwiftChatStoragePlugin.swift | 12 +- .../chat_storage/lib/chat_storage.dart | 24 +- 20 files changed, 761 insertions(+), 346 deletions(-) diff --git a/lib/data/models/message_model.dart b/lib/data/models/message_model.dart index 09744a939..e2c534200 100644 --- a/lib/data/models/message_model.dart +++ b/lib/data/models/message_model.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; class Message { + final String sessionid; // 会话ID final bool isUser; //标识信息是否来自用户还是AI final String text; //信息文本内容 final DateTime timestamp; // 消息时间戳 @@ -10,9 +11,12 @@ class Message { final bool hasImage; // 是否包含图片 final String? imagePath; // 图片路径 final Map? meta; //卡片数据 + final List? functionCalls; // 函数调用 + bool isToolCallsExpanded; // 工具调用是否展开 Message({ // 构造函数 + required this.sessionid, // 会话ID required this.isUser, required this.text, DateTime? timestamp, @@ -20,11 +24,14 @@ class Message { this.hasImage = false, this.imagePath, this.meta, + this.functionCalls, + this.isToolCallsExpanded = false, // 默认不展开 }) : this.timestamp = timestamp ?? DateTime.now(); // 从JSON构造函数 factory Message.fromJson(Map json) { return Message( + sessionid: json['sessionid'] as String, // 会话ID isUser: json['isUser'] as bool, text: json['text'] as String, timestamp: DateTime.parse(json['timestamp'] as String), @@ -32,6 +39,8 @@ class Message { hasImage: json['hasImage'] as bool? ?? false, imagePath: json['imagePath'] as String?, meta: json['meta'] as Map?, + functionCalls: json['functionCalls'] as List?, + isToolCallsExpanded: json['isToolCallsExpanded'] as bool? ?? false, ); } @@ -45,12 +54,15 @@ class Message { 'hasImage': hasImage, 'imagePath': imagePath, 'meta': meta, + 'functionCalls': functionCalls, + 'isToolCallsExpanded': isToolCallsExpanded, }; } // 创建一个加载中的消息 factory Message.loading() { return Message( + sessionid: '', // 会话ID isUser: false, text: '', timestamp: DateTime.now(), @@ -60,6 +72,7 @@ class Message { // 复制并修改 Message copyWith({ + required String sessionid, // 会话ID bool? isUser, String? text, DateTime? timestamp, @@ -67,8 +80,11 @@ class Message { bool? hasImage, String? imagePath, Map? meta, + List? functionCalls, + bool? isToolCallsExpanded, }) { return Message( + sessionid: sessionid, // 会话ID isUser: isUser ?? this.isUser, text: text ?? this.text, timestamp: timestamp ?? this.timestamp, @@ -76,6 +92,13 @@ class Message { hasImage: hasImage ?? this.hasImage, imagePath: imagePath ?? this.imagePath, meta: meta ?? this.meta, + functionCalls: functionCalls ?? this.functionCalls, + isToolCallsExpanded: isToolCallsExpanded ?? this.isToolCallsExpanded, ); } + + // 切换工具调用展开状态 + void toggleToolCallsExpanded() { + isToolCallsExpanded = !isToolCallsExpanded; + } } diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index 6119f49ef..c11722a84 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -1,25 +1,21 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:ffi'; import 'dart:io'; import 'package:amap_service/amap_service.dart'; -import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:uuid/uuid.dart'; import 'package:voitrans/data/models/appconfig.dart'; import 'package:voitrans/data/models/song_model.dart'; import 'package:voitrans/data/services/ble_manager.dart'; import 'package:voitrans/data/services/bluetooth_manager.dart'; import 'package:voitrans/data/services/qq_music.dart'; import 'package:flutter_intro/flutter_intro.dart'; -import '../../../data/services/ble_manager.dart'; -import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/novice_guide_service.dart'; import '/core/utils/upload_oss.dart'; import '/data/models/user_Info.dart'; import '/data/services/music_manager.dart'; import '/data/services/navigation_manager.dart'; -import '/data/services/network/api.dart'; import 'package:ble_service/ble_service.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -46,6 +42,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 聊天消息列表 final messages = [].obs; + final currsessionid = ''.obs; // UI控制器 final TextEditingController textController = TextEditingController(); @@ -138,7 +135,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { final TAG = 'AgentController'; var AppDir = "".obs; // 会话ID - final String sessionId = 'default_agent'; + final String agentId = 'default_agent'; // 分页加载相关状态 final isLoadingMore = false.obs; // 是否正在加载更多消息 @@ -312,7 +309,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { Logger.i(TAG, '正在加载聊天历史...'); //通过本地插件ChatStorage获取聊天记录 final result = await ChatStorage.getMessages( - sessionId: sessionId, //会话ID + agentId: agentId, //会话ID page: 1, //从第一页开始 pageSize: pageSize, //每页20条记录 ); @@ -367,7 +364,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { Logger.i(TAG, '正在加载第 $nextPage 页历史消息...'); final result = await ChatStorage.getMessages( - sessionId: sessionId, + agentId: agentId, page: nextPage, pageSize: pageSize, ); @@ -421,6 +418,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { : null; return Message( + sessionid: msg.sessionId, // 会话ID isUser: msg.sender == 'user', text: msg.message, meta: meta, @@ -488,6 +486,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 添加初始欢迎消息 void _addWelcomeMessage() { messages.add(Message( + sessionid: "", // 会话ID isUser: false, text: 'welcomeMessage'.tr, // 你好!我是**小言**,我能帮你: )); @@ -504,6 +503,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { // isTextInputMode.refresh(); // 只标记识别开始,不添加消息 Logger.i(TAG, '识别开始'); + break; case AgentServiceEventType.recognitionStopped: isListening.value = false; @@ -527,25 +527,29 @@ class AgentController extends GetxController with WidgetsBindingObserver { break; case AgentServiceEventType.recognizing: + final sessionid = event.data['sessionid'] ?? ''; final text = event.data['text'] ?? ''; currentText.value = text; // 保留当前文本,以便其他地方使用 Logger.i(TAG, '识别中间结果: $text'); if (text.isNotEmpty) { // 查找是否有正在识别中的消息 - final index = messages - .lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && msg.isUser); if (index >= 0) { // 更新已有的识别中消息 messages[index] = Message( + sessionid: sessionid, // 会话ID isUser: true, text: text, isRecognizing: true, timestamp: messages[index].timestamp, ); } else { + currsessionid.value = event.data['sessionid'] ?? ''; // 没有识别中的消息,创建新消息 messages.add(Message( + sessionid: sessionid, // 会话ID isUser: true, text: text, isRecognizing: true, @@ -556,26 +560,33 @@ class AgentController extends GetxController with WidgetsBindingObserver { break; case AgentServiceEventType.recognitionResult: + final sessionid = event.data['sessionid'] ?? ''; final text = event.data['text'] ?? ''; Logger.i(TAG, '识别最终结果: $text'); // 查找是否有正在识别中的消息 - final index = - messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && msg.isUser); if (text.isNotEmpty) { if (index >= 0) { // 将识别中的消息转换为最终消息 messages[index] = Message( + sessionid: sessionid, isUser: true, text: text, isRecognizing: false, timestamp: messages[index].timestamp, + functionCalls: messages[index].functionCalls, + isToolCallsExpanded: messages[index].isToolCallsExpanded, ); // messages.refresh(); } else { // 如果没有找到识别中的消息,添加新消息 - messages - .add(Message(isUser: true, text: text, isRecognizing: false)); + messages.add(Message( + sessionid: sessionid, + isUser: true, + text: text, + isRecognizing: false)); } isProcessing.value = true; @@ -590,24 +601,31 @@ class AgentController extends GetxController with WidgetsBindingObserver { break; //识别取消(网络波动会触发) case AgentServiceEventType.recognitionCanceled: + final sessionid = event.data['sessionid'] ?? ''; // 查找是否有正在识别中的消息 - final index = - messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && msg.isUser); if (currentText.value.isNotEmpty) { if (index >= 0) { // 将识别中的消息转换为最终消息 messages[index] = Message( + sessionid: sessionid, // 会话ID isUser: true, text: currentText.value, isRecognizing: false, timestamp: messages[index].timestamp, + functionCalls: messages[index].functionCalls, + isToolCallsExpanded: messages[index].isToolCallsExpanded, ); // messages.refresh(); } else { // 如果没有找到识别中的消息,添加新消息 messages.add(Message( - isUser: true, text: currentText.value, isRecognizing: false)); + sessionid: sessionid, + isUser: true, + text: currentText.value, + isRecognizing: false)); } isProcessing.value = true; @@ -654,29 +672,27 @@ class AgentController extends GetxController with WidgetsBindingObserver { case AgentServiceEventType.assistantToken: if (!isProcessing.value) isProcessing.value = true; - + final sessionid = event.data['sessionid'] ?? ''; final token = event.data['token'] ?? ''; - final responseId = event.data['responseId'] ?? ''; // Logger.i(TAG, 'AI回复Token: $token, responseId: $responseId'); if (token.isNotEmpty) { // 如果是新的回复或者响应ID改变,创建新消息 - if (_isNewAssistantResponse || - (_currentResponseId != responseId && responseId.isNotEmpty)) { - messages.add(Message(isUser: false, text: token)); + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && !msg.isUser); + if (index == -1) { + messages.add( + Message(sessionid: sessionid, isUser: false, text: token)); _isNewAssistantResponse = false; - _currentResponseId = responseId; } else { - // 否则更新最后一条消息 - final lastIndex = messages.length - 1; - - if (lastIndex >= 0 && !messages[lastIndex].isUser) { - messages[lastIndex] = Message( - isUser: false, - text: messages[lastIndex].text + token, - meta: messages[lastIndex].meta, - timestamp: messages[lastIndex].timestamp, - ); - } + messages[index] = Message( + sessionid: sessionid, + isUser: false, + text: messages[index].text + token, + meta: messages[index].meta, + timestamp: messages[index].timestamp, + functionCalls: messages[index].functionCalls, + isToolCallsExpanded: messages[index].isToolCallsExpanded, + ); } // 使用reverse:true时通常不需要手动滚动 } @@ -713,10 +729,10 @@ class AgentController extends GetxController with WidgetsBindingObserver { case AgentServiceEventType.autoStop: isListening.value = false; - + final sessionid = event.data['sessionid'] ?? ''; // 移除临时的识别消息 - final index = - messages.lastIndexWhere((msg) => msg.isRecognizing && msg.isUser); + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && msg.isUser); if (index >= 0) { // 只有当消息为空或文本太短时才移除 if (messages[index].text.trim().length < 2) { @@ -725,21 +741,25 @@ class AgentController extends GetxController with WidgetsBindingObserver { } else { // 否则转换为正式消息 messages[index] = Message( + sessionid: sessionid, isUser: true, text: messages[index].text, timestamp: messages[index].timestamp, + functionCalls: messages[index].functionCalls, + isToolCallsExpanded: messages[index].isToolCallsExpanded, ); // messages.refresh(); // 发送此消息到AI处理 isProcessing.value = true; _isNewAssistantResponse = true; - _handleAutoStop(index); + _handleAutoStop(sessionid, index); } } break; case AgentServiceEventType.functionCall: + final sessionid = event.data['sessionid'] ?? ''; final functionName = event.data['name'] ?? ''; final arguments = event.data['arguments'] ?? ''; // debugPrint('Fultter 开始调用 mcp: $functionName, $arguments'); @@ -748,15 +768,39 @@ class AgentController extends GetxController with WidgetsBindingObserver { endPhoneCall(); //退出语音交互,同时退出打电话模式 } } - + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && !msg.isUser); + if (index == -1) { + messages.add(Message( + sessionid: sessionid, + isUser: false, + text: '', + functionCalls: [functionName])); + _isNewAssistantResponse = false; + } else { + // 否则更新最后一条消息 + final lastIndex = messages.length - 1; + + if (lastIndex >= 0 && !messages[lastIndex].isUser) { + messages[lastIndex] = Message( + sessionid: sessionid, + isUser: false, + text: messages[lastIndex].text, + meta: messages[lastIndex].meta, + timestamp: messages[lastIndex].timestamp, + functionCalls: messages[lastIndex].functionCalls ?? [] + ..add(functionName), + ); + } + } break; case AgentServiceEventType.functionCallResult: - final responseId = event.data['responseId'] ?? ''; + final sessionid = event.data['sessionid'] ?? ''; // final result = event.data['result']; final metaStr = event.data['meta'] ?? ''; final functionCall = event.data['function_call'] ?? ''; - Logger.i(TAG, 'Flutter 结束调用 mcp: $metaStr'); + Logger.i(TAG, 'Flutter 结束调用 $sessionid mcp: $metaStr'); if (metaStr.isNotEmpty) { Map metaResult = {}; metaResult = jsonDecode(metaStr) as Map; @@ -771,25 +815,31 @@ class AgentController extends GetxController with WidgetsBindingObserver { //不回复直接返回 break; } + final index = messages.lastIndexWhere( + (msg) => msg.sessionid == sessionid && !msg.isUser); + Logger.i(TAG, 'Flutter 结束调用 卡片数据 $index $metaResult'); // 判断是否为新的回复或响应ID是否改变 - if (_isNewAssistantResponse || _currentResponseId != responseId) { + if (index == -1) { // 添加新消息 - messages - .add(Message(isUser: false, text: "", meta: metaResult)); + messages.add(Message( + sessionid: sessionid, + isUser: false, + text: "", + meta: metaResult)); _isNewAssistantResponse = false; - _currentResponseId = responseId; } else { // 更新最后一条消息 - final lastIndex = messages.length - 1; - if (lastIndex >= 0 && !messages[lastIndex].isUser) { - messages[lastIndex] = Message( - isUser: false, - text: messages[lastIndex].text, - meta: metaResult, - timestamp: messages[lastIndex].timestamp, - ); - } + messages[index] = Message( + sessionid: sessionid, + isUser: false, + text: messages[index].text, + meta: metaResult, + timestamp: messages[index].timestamp, + functionCalls: messages[index].functionCalls, + isToolCallsExpanded: messages[index].isToolCallsExpanded, + ); } + messages.refresh(); if (metaResult.containsKey("maps_route_navigation")) { final maps_route_navigation = metaResult["maps_route_navigation"]; @@ -847,6 +897,9 @@ class AgentController extends GetxController with WidgetsBindingObserver { } } } + } else { + // 其他函数调用 + Logger.i(TAG, 'Flutter 结束调用 解析失败 $metaStr'); } } if (functionCall.contains("music")) { @@ -893,6 +946,7 @@ class AgentController extends GetxController with WidgetsBindingObserver { // ✅ 创建新的 Message 替换原来的 messages[index] = Message( + sessionid: result.sessionid, isUser: result.isUser, text: result.text, meta: newMeta, @@ -1002,10 +1056,13 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 标记为新的AI回复 _isNewAssistantResponse = true; newAIReplyCompleted.value = false; + currsessionid.value = const Uuid().v4(); + final sessionid = currsessionid.value; // 根据TTS状态决定是否朗读 - await AgentService.processTextInput(text, + AgentService.processTextInput(sessionid, text, speakResponse: isTtsEnabled.value); - final message = Message(isUser: true, text: text); + final message = Message(sessionid: sessionid, isUser: true, text: text); + Logger.i(TAG, '发送AI消息:$sessionid $text'); messages.add(message); textController.clear(); } catch (e) { @@ -1029,12 +1086,17 @@ class AgentController extends GetxController with WidgetsBindingObserver { var ImageUrl = await UploadOss.upload( filepath: permanentImagePath, rootDir: 'User/${User.instance.uid}/ChatImages'); - Logger.i(TAG, '图片上传: $permanentImagePath URL: $ImageUrl'); + // 显示的文本,如果没有提供则使用默认值 final displayText = text?.isNotEmpty == true ? text! : '[图片]'; + currsessionid.value = const Uuid().v4(); + final sessionid = currsessionid.value; + Logger.i(TAG, + '发送AI图片消息:$sessionid $displayText $permanentImagePath URL: $ImageUrl'); // 添加用户消息 final message = Message( + sessionid: sessionid, isUser: true, text: displayText, hasImage: true, @@ -1055,9 +1117,11 @@ class AgentController extends GetxController with WidgetsBindingObserver { // 保存消息到持久化存储 //await _saveMessageToStorage(message); - + currsessionid.value = const Uuid().v4(); + final sessionid = currsessionid.value; // 调用Agent Service处理图片,考虑TTS状态 await AgentService.processImageInput( + sessionid, relativeImagePath, ImageUrl, text: text ?? '', @@ -1855,11 +1919,13 @@ class AgentController extends GetxController with WidgetsBindingObserver { } // 修改自动停止处理,遵循TTS设置 - void _handleAutoStop(int index) { + void _handleAutoStop(String sessionid, int index) { // 发送此消息到AI处理 isProcessing.value = true; + // 标记为新的AI回复 _isNewAssistantResponse = true; - AgentService.processTextInput(messages[index].text, + currsessionid.value = sessionid; + AgentService.processTextInput(sessionid, messages[index].text, speakResponse: isTtsEnabled.value); } @@ -2612,4 +2678,15 @@ class AgentController extends GetxController with WidgetsBindingObserver { Get.snackbar('错误', '打开苹果地图时发生错误'); } } + + Future updateToolCallsExpanded( + String sessionid, + bool isExpanded, + ) async { + final message = messages.firstWhere( + (msg) => msg.sessionid == sessionid && msg.isUser == false, + ); + message.isToolCallsExpanded = isExpanded; + messages.refresh(); + } } diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index c9c456491..3f4dd0dd1 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -287,6 +287,7 @@ class _AgentViewState extends State with WidgetsBindingObserver { isDarkMode, details, message); }, child: MessageBubble( + sessionid: message.sessionid, isUser: message.isUser, message: message.text, timestamp: message.timestamp, @@ -294,6 +295,9 @@ class _AgentViewState extends State with WidgetsBindingObserver { hasImage: message.hasImage, imagePath: message.imagePath, meta: message.meta, + functionCalls: message.functionCalls, + isToolCallsExpanded: + message.isToolCallsExpanded, ), ), ), @@ -307,6 +311,7 @@ class _AgentViewState extends State with WidgetsBindingObserver { context, isDarkMode, details, message); }, child: MessageBubble( + sessionid: message.sessionid, isUser: message.isUser, message: message.text, timestamp: message.timestamp, @@ -314,6 +319,9 @@ class _AgentViewState extends State with WidgetsBindingObserver { hasImage: message.hasImage, imagePath: message.imagePath, meta: message.meta, + functionCalls: message.functionCalls, + isToolCallsExpanded: + message.isToolCallsExpanded, ), ), ); diff --git a/lib/modules/agent/views/message_bubble.dart b/lib/modules/agent/views/message_bubble.dart index f5b24bd04..fed515b23 100644 --- a/lib/modules/agent/views/message_bubble.dart +++ b/lib/modules/agent/views/message_bubble.dart @@ -18,6 +18,7 @@ import '../controllers/agent_controller.dart'; /// 消息气泡组件 class MessageBubble extends GetView { + final String sessionid; final bool isUser; final String message; final DateTime timestamp; @@ -25,9 +26,11 @@ class MessageBubble extends GetView { final bool hasImage; final String? imagePath; final Map? meta; - + final List? functionCalls; + final bool isToolCallsExpanded; const MessageBubble({ Key? key, + required this.sessionid, required this.isUser, required this.message, required this.timestamp, @@ -35,6 +38,8 @@ class MessageBubble extends GetView { this.hasImage = false, this.imagePath, this.meta, + this.functionCalls, + this.isToolCallsExpanded = false, }) : super(key: key); @override @@ -75,7 +80,24 @@ class MessageBubble extends GetView { ), ], ), - child: _buildMessageContent(context), + child: Stack( + children: [ + // 主要消息内容 + Padding( + padding: (!isUser && + functionCalls != null && + functionCalls!.isNotEmpty) + ? const EdgeInsets.only(top: 20) + : EdgeInsets.zero, + child: _buildMessageContent(context), + ), + //工具调用流程显示(仅在AI消息且有functionCalls时显示) + if (!isUser && + functionCalls != null && + functionCalls!.isNotEmpty) + _buildFunctionCallsWidget(context), + ], + ), ), const SizedBox(height: 4), // 识别中的消息不显示时间 @@ -89,7 +111,7 @@ class MessageBubble extends GetView { Text( _formatTimestamp(timestamp), style: TextStyle( - fontSize: 10, + fontSize: 14, color: Theme.of(context).brightness == Brightness.dark ? Colors.grey[400] : Colors.grey[500], @@ -160,10 +182,21 @@ class MessageBubble extends GetView { ); } - if (!isUser && message.isEmpty && meta == null) { + if (!isUser && message.isEmpty && functionCalls == null && meta == null) { return _buildTypingIndicator(); } + // 如果只有工具调用而没有文本内容,显示一个占位符确保气泡有足够宽度 + if (!isUser && message.isEmpty && functionCalls != null && meta == null) { + return Container( + constraints: const BoxConstraints( + minWidth: 200, // 确保最小宽度 + minHeight: 20, // 确保最小高度 + ), + child: const SizedBox.shrink(), // 透明占位符 + ); + } + // 如果是图片消息,显示图片 if (hasImage && imagePath != null) { // 如果消息是默认的[图片]标记,只显示图片 @@ -369,9 +402,12 @@ class MessageBubble extends GetView { // } } + // 创建消息内容Widget + Widget messageContent; + // 使用Markdown渲染AI消息,用户消息仍然使用普通文本 if (!isUser) { - return MarkdownBody( + messageContent = MarkdownBody( data: message, styleSheet: MarkdownStyleSheet( p: TextStyle( @@ -442,21 +478,34 @@ class MessageBubble extends GetView { ), //selectable: true, ); + } else { + // 用户消息仍然使用普通文本 + messageContent = Text( + message, + style: TextStyle( + color: isUser + ? Colors.white + : (Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.9) + : Colors.black87), + fontSize: 14, + height: 1.4, + fontWeight: FontWeight.w400, + ), + ); } - // 用户消息仍然使用普通文本 - return Text( - message, - style: TextStyle( - color: isUser - ? Colors.white - : (Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(0.9) - : Colors.black87), - fontSize: 14, - height: 1.4, - fontWeight: FontWeight.w400, - ), - ); + + // 如果是AI消息且有工具调用,确保最小宽度以防止工具调用列表越界 + if (!isUser && functionCalls != null && functionCalls!.isNotEmpty) { + return Container( + constraints: const BoxConstraints( + minWidth: 200, // 确保最小宽度,防止工具调用列表越界 + ), + child: messageContent, + ); + } + + return messageContent; } /// 构建输入中的指示器 @@ -550,6 +599,155 @@ class MessageBubble extends GetView { return ''; } } + + /// 构建工具调用流程组件 + Widget _buildFunctionCallsWidget(BuildContext context) { + return Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 0, vertical: 0), // 减少垂直padding + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 可点击的标题行 + GestureDetector( + onTap: () async { + await controller.updateToolCallsExpanded( + sessionid, + !isToolCallsExpanded, + ); + }, + child: Container( + height: 20, // 增加固定高度,给字体更多空间 + decoration: BoxDecoration( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[800]?.withOpacity(0.8) + : Colors.grey[100]?.withOpacity(0.9), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + width: 0.5, + ), + ), + child: Row( + children: [ + // 显示最后一个工具名称 + Expanded( + child: Text( + functionCalls != null && functionCalls!.isNotEmpty + ? functionCalls!.last + : '工具调用', + style: TextStyle( + fontSize: 12, // 适当增大字体 + color: + Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.7) + : Colors.black54, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + // 箭头放在右侧 + Icon( + isToolCallsExpanded + ? Icons.keyboard_arrow_up // 展开时显示向上箭头 + : Icons.keyboard_arrow_down, // 收起时显示向下箭头 + size: 14, // 减小图标大小 + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.7) + : Colors.black54, + ), + ], + ), + ), + ), + // 展开的工具调用列表 + if (isToolCallsExpanded && functionCalls != null) + Container( + margin: const EdgeInsets.only(top: 2), // 减少顶部间距 + padding: const EdgeInsets.all(6), // 减少内边距 + constraints: const BoxConstraints( + maxHeight: 120, // 限制最大高度,避免超界 + ), + decoration: BoxDecoration( + // 添加更强的背景色来遮挡下层UI + color: Theme.of(context).brightness == Brightness.dark + ? Colors.grey[900]?.withOpacity(0.95) // 深色模式下使用深灰色背景 + : Colors.white.withOpacity(0.95), // 浅色模式下使用白色背景 + borderRadius: BorderRadius.circular(4), // 减小圆角 + border: Border.all( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.2) + : Colors.black.withOpacity(0.2), + width: 0.5, + ), + // 添加阴影增强遮挡效果 + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: SingleChildScrollView( + // 添加滚动,防止超界 + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: functionCalls!.asMap().entries.map((entry) { + int index = entry.key; + String call = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: index < functionCalls!.length - 1 + ? 2 + : 0), // 减少间距 + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${index + 1}. ', + style: TextStyle( + fontSize: 12, // 修改为更小的字体 + color: Theme.of(context).brightness == + Brightness.dark + ? Colors.white.withOpacity(0.6) + : Colors.black45, + fontWeight: FontWeight.w500, + ), + ), + Expanded( + child: Text( + call, + style: TextStyle( + fontSize: 12, // 修改为更小的字体 + color: Theme.of(context).brightness == + Brightness.dark + ? Colors.white.withOpacity(0.8) + : Colors.black87, + height: 1.1, // 减小行高 + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ], + ), + )); + } } /// 动画点组件 diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 84dfaaca3..8b8f90d47 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -27,7 +27,6 @@ import kotlinx.coroutines.sync.Mutex import com.deep_voice.speech.tts.TtsEvent import com.deep_voice.speech.tts.TtsEventListener import com.deep_voice.speech.tts.TtsEventType -import java.util.UUID import okhttp3.* import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody @@ -88,7 +87,7 @@ object AgentService : CoroutineScope { private lateinit var chatStorageHelper: ChatStorageHelper // 会话ID,用于区分不同聊天上下文 - private val sessionId = "default_agent" + private val agentId = "default_agent" // 服务端连接 private var serverurl = "" private var usertoken = "" @@ -602,7 +601,7 @@ object AgentService : CoroutineScope { Log.d(TAG, "recognizeCallback") val success = azureAsrHelper?.setupEventListeners(object : AzureAsrHelper.ContinuousRecognizeCallback { - override fun onRecognizing(recognizing: String, detectedLanguage: String) { + override fun onRecognizing(sessiondid:String, recognizing: String, detectedLanguage: String) { if (recognizing.isNotEmpty()&&isKeepResult==true) { // 检测到语音,更新状态 @@ -646,16 +645,17 @@ object AgentService : CoroutineScope { } } - override fun onResult(result: String, detectedLanguage: String) { + override fun onResult(sessiondid:String,result: String, detectedLanguage: String) { if (result.isNotEmpty()&&isKeepResult==true) {//加入是否结束要输出最后的结果判读 sendEvent( "recognition_result", mapOf( + "sessiondid" to sessiondid, "text" to result, "language" to detectedLanguage ) ) - processWithChatApi(result) + processWithChatApi(sessiondid,result) } @@ -665,8 +665,8 @@ object AgentService : CoroutineScope { } - override fun onSessionStarted() { - sendEvent("recognition_started", mapOf("status" to "started")) + override fun onSessionStarted(sessiondid:String) { + sendEvent("recognition_started", mapOf("sessiondid" to sessiondid, "status" to "started")) // 使用模式感知的空闲检测启动方法 audioPlayer?.playAudio(R.raw.start) @@ -674,33 +674,35 @@ object AgentService : CoroutineScope { - override fun onSessionStopped() { - sendEvent("recognition_stopped", mapOf("status" to "stopped")) + override fun onSessionStopped(sessiondid:String) { + sendEvent("recognition_stopped", mapOf("sessiondid" to sessiondid, "status" to "stopped")) _isRecognitionActive.set(false) stopIdleCheck() audioPlayer?.playAudio(R.raw.stop) } - override fun onCanceled(reason: String, errorDetails: String) { + override fun onCanceled(sessiondid: String,reason: String, errorDetails: String) { _isRecognitionActive.set(false) stopIdleCheck() Log.d(TAG, "recognition_canceled:") BleService.closeCodec() sendEvent( "recognition_canceled", mapOf( + "sessiondid" to sessiondid, "reason" to reason, "details" to errorDetails ) ) } - override fun onError(code: Int, error: String) { + override fun onError(sessiondid:String, code: Int, error: String) { _isRecognitionActive.set(false) stopIdleCheck() BleService.closeCodec() Log.d(TAG, "语音识别错误: $error") sendEvent( "error", mapOf( + "sessiondid" to sessiondid, "code" to code, "message" to error ) @@ -802,7 +804,7 @@ object AgentService : CoroutineScope { /** * 处理文本输入 */ - fun processTextInput(text: String, speakResponse: Boolean = false): Boolean { + fun processTextInput(sessionid: String,text: String, speakResponse: Boolean = false): Boolean { if (!isInitialized) { Log.e(TAG, "服务未初始化") sendEvent("error", mapOf("code" to "NOT_INITIALIZED", "message" to "服务未初始化")) @@ -814,17 +816,16 @@ object AgentService : CoroutineScope { sendEvent("error", mapOf("code" to "EMPTY_TEXT", "message" to "文本输入不能为空")) return false } - // 使用ChatAPI处理文本 - processWithChatApi(text, speakResponse) + processWithChatApi(sessionid,text, speakResponse) return true } /** * 使用ChatAPI处理语音识别结果 */ - private fun processWithChatApi(text: String) { - processWithChatApi(text, true) + private fun processWithChatApi(sessionid:String,text: String) { + processWithChatApi(sessionid,text, true) } /** @@ -834,6 +835,7 @@ object AgentService : CoroutineScope { * @param speakResponse 是否使用TTS朗读回复 */ private fun processWithChatApi( + sessionid: String, text: String, speakResponse: Boolean = true ) { @@ -841,7 +843,7 @@ object AgentService : CoroutineScope { // 创建用户文本消息并处理 val userMessage = createUserMessage(text) - processWithChatApiInternal(userMessage, text, speakResponse) + processWithChatApiInternal(sessionid,userMessage, text, speakResponse) } /** @@ -852,6 +854,7 @@ object AgentService : CoroutineScope { * @param speakResponse 是否朗读回复 */ private fun processImageWithChatApi( + sessionid: String, imageBase64: String, text: String = "", speakResponse: Boolean = false @@ -861,7 +864,7 @@ object AgentService : CoroutineScope { Log.d(TAG, "liwei--------- 图像Mesage: ${userMessage}") // 图片描述用于存储 val displayText = text.ifEmpty { "[图片]" } - processWithChatApiInternal(userMessage, displayText, speakResponse, true) + processWithChatApiInternal(sessionid,userMessage, displayText, speakResponse, true) } /** @@ -874,21 +877,21 @@ object AgentService : CoroutineScope { * @param imagePath 图片路径(如果有) */ private fun processWithChatApiInternal( + sessionid: String, userMessage: JSONObject, displayText: String, speakResponse: Boolean = true, hasImage: Boolean = false, imagePath: String? = null ) { - Log.d(TAG, "内部方法:通用的ChatAPI处理逻辑") // 如果有正在进行的AI流式输出,先停止它 audioPlayer?.stopAudio() stopAiStream() stopTts() + currsessionId = sessionid // 记录新的AI任务 currentAiJob = launch { try { - currsessionId = UUID.randomUUID().toString() // 设置状态为正在流式输出 _isAiStreaming.set(true) Log.d(TAG, "processWithChatApiInternal") @@ -958,7 +961,7 @@ object AgentService : CoroutineScope { } // Log.e("ChatApiService", "liwei---------sendMessageStream messages:${messagesWithSystemPrompt}") chatApiService.sendMessageStream( - currsessionId, + sessionid, messagesWithSystemPrompt, object : StreamCallback { @@ -984,15 +987,17 @@ object AgentService : CoroutineScope { override fun onToken(sessionid: String, token: String) { try { if (sessionid != currsessionId) { +// Log.d(TAG, "liwei--------------- AI Call onToken 会话过期 $sessionid-------$currsessionId") return } responseBuilder.append(token) +// Log.d(TAG, "liwei--------------- AI Call onToken:$token speakResponse:$speakResponse reply:$reply broadcast:$broadcast") if (speakResponse && reply && broadcast && token.isNotEmpty()) { ttsService?.speakStream(token) } if (reply && token.isNotEmpty()) { // 发送流式回复token - sendEvent("assistant_token", mapOf("token" to token)) + sendEvent("assistant_token", mapOf("sessionid" to sessionid,"token" to token)) } } catch (e: Exception) { Log.e(TAG, "liwei--------------- AI Call onToken 异常", e) @@ -1011,6 +1016,7 @@ object AgentService : CoroutineScope { val response = responseBuilder.toString() // 发送完整回复,包含是否有图片的标记 val responseData = mutableMapOf( + "sessionid" to sessionid, "text" to response, "userInput" to displayText ) @@ -1027,6 +1033,7 @@ object AgentService : CoroutineScope { } // 保存聊天记录 saveChatMessage( + sessionid, displayText, response, aiMetadata, @@ -1045,6 +1052,7 @@ object AgentService : CoroutineScope { Log.e(TAG, "AI处理出错", e) sendEvent( "error", mapOf( + "sessionid" to sessionid, "code" to "AI_ERROR", "message" to e.message.toString() ) @@ -1064,6 +1072,7 @@ object AgentService : CoroutineScope { val name = call.getString("name") sendEvent( "function_call", mapOf( + "sessionid" to sessionid, "name" to name, "arguments" to call.toString() ) @@ -1072,6 +1081,10 @@ object AgentService : CoroutineScope { BleService.closeCodec() stopRecognition() } + + //播报工具调用 + ttsService?.speakStream("正在调用工具:${name}") + } catch (e: Exception) { Log.e(TAG, "liwei--------------- AI Call onFunctionCall 异常", e) } @@ -1083,6 +1096,11 @@ object AgentService : CoroutineScope { functionCallResult: JSONObject ) { try { + Log.d( + TAG, + "liwei--------------- AI Call onFunctionCallResult ${sessionid}:${functionCallResult.toString()}", + + ) audioPlayer?.stopAudio() val name = functionCall.get("name") as String; val resultmap = autoHandleFcunCallResult(name, functionCallResult); @@ -1093,6 +1111,7 @@ object AgentService : CoroutineScope { aiMetadata = resultmap["meta"] as String; sendEvent( "function_call_result", mapOf( + "sessionid" to sessionid, "function_call" to functionCall.toString(), "result" to functionCallResult.toString(), "meta" to aiMetadata, @@ -1101,16 +1120,12 @@ object AgentService : CoroutineScope { }else{ sendEvent( "function_call_result", mapOf( + "sessionid" to sessionid, "function_call" to functionCall.toString(), "result" to functionCallResult.toString(), ) ) } - - Log.e( - TAG, - "liwei--------------- AI Call onFunctionCallResult broadcast:$broadcast reply:$reply iscard:$iscard" - ) } catch (e: Exception) { Log.e( TAG, @@ -1127,6 +1142,7 @@ object AgentService : CoroutineScope { Log.e(TAG, "liwei--------------- AI处理出错", e) sendEvent( "error", mapOf( + "sessionid" to sessionid, "code" to errorType, "message" to e.message.toString() ) @@ -1152,7 +1168,7 @@ object AgentService : CoroutineScope { } // 获取最近消息 - val recentMessages = chatStorageHelper.getRecentMessages(sessionId, 10) + val recentMessages = chatStorageHelper.getRecentMessages(agentId, 10) if (recentMessages.isEmpty()) { return } @@ -1389,6 +1405,7 @@ object AgentService : CoroutineScope { * 保存聊天消息 */ private fun saveChatMessage( + sessionid: String, userMessage: String, assistantMessage: String, aiMetadata: String, @@ -1399,7 +1416,8 @@ object AgentService : CoroutineScope { try { // 保存用户消息 val userMessageId = chatStorageHelper.saveMessage( - sessionId = sessionId, + agentId = agentId, + sessionId = sessionid, message = userMessage, sender = "user", metadata = userMetadata @@ -1409,7 +1427,8 @@ object AgentService : CoroutineScope { if (assistantMessage.isNotEmpty() || aiMetadata.isNotEmpty()) { // 保存AI回复 val assistantMessageId = chatStorageHelper.saveMessage( - sessionId = sessionId, + agentId = agentId, + sessionId = sessionid, message = assistantMessage, sender = "assistant", metadata = aiMetadata @@ -1435,7 +1454,7 @@ object AgentService : CoroutineScope { fun clearChatHistory(callback: (Boolean) -> Unit) { launch { try { - val success = chatStorageHelper.deleteMessages(sessionId) + val success = chatStorageHelper.deleteMessages(agentId) if (success) { synchronized(historyMessages) { @@ -1542,6 +1561,7 @@ object AgentService : CoroutineScope { * 处理图片输入 */ fun processImageInput( + sessionid: String, imagePath: String, imageUrl: String, text: String = "", @@ -1593,7 +1613,7 @@ object AgentService : CoroutineScope { ) Log.d(TAG, "liwei--------- 图像Mesage: ${userMessage}") // 处理消息并传入存储路径 - processWithChatApiInternal(userMessage, displayText, speakResponse, true, imagePath) + processWithChatApiInternal(sessionid,userMessage, displayText, speakResponse, true, imagePath) } catch (e: Exception) { Log.e(TAG, "处理图片失败: ${e.message}") sendEvent( diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt index e36f91294..513221bf8 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentServicePlugin.kt @@ -144,7 +144,12 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } "processTextInput" -> { + val sessionid = call.argument("sessionid") val text = call.argument("text") + if (sessionid.isNullOrEmpty()) { + result.error("INVALID_ARGUMENTS", "回话id不能为空", null) + return + } if (text.isNullOrEmpty()) { result.error("INVALID_ARGUMENTS", "文本不能为空", null) return @@ -154,6 +159,7 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream try { val isProcessing = AgentService.processTextInput( + sessionid = sessionid, text = text, speakResponse = speakResponse ) @@ -166,6 +172,11 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream } "processImageInput" -> { + val sessionid = call.argument("sessionid") + if (sessionid.isNullOrEmpty()) { + result.error("INVALID_ARGUMENTS", "回话id不能为空", null) + return + } val imagePath = call.argument("imagePath") if (imagePath.isNullOrEmpty()) { result.error("INVALID_ARGUMENTS", "图片路径不能为空", null) @@ -181,6 +192,7 @@ class AgentServicePlugin : FlutterPlugin, MethodCallHandler, EventChannel.Stream try { val isProcessing = AgentService.processImageInput( + sessionid=sessionid, imagePath = imagePath, imageUrl = imageUrl, text = text, diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt index 80c9b568b..03dee6109 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleAgent.kt @@ -85,8 +85,8 @@ Log.d(TAG, "手动启动语音识别: ") /** * 处理文本输入 */ - fun processTextInput(text: String, speakResponse: Boolean = false): Boolean { - return AgentService.processTextInput(text, speakResponse) + fun processTextInput(sessionId: String,text: String, speakResponse: Boolean = false): Boolean { + return AgentService.processTextInput(sessionId,text, speakResponse) } /** diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index 0643f2510..cc11359ee 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -59,7 +59,7 @@ class AgentServiceImpl: NSObject { } // 会话ID,用于区分不同聊天上下文 - private let sessionId = "default_agent" + private let agentId = "default_agent" private var isInitialized: Bool = false private var isRecognizing: Bool = false @@ -565,7 +565,7 @@ audioStream.saveAudioDataTo(data: audioData) return interrupted } - func processTextInput(_ text: String, speakResponse: Bool) -> Bool { + func processTextInput(sessionid:String, _ text: String, speakResponse: Bool) -> Bool { os_log("收到文本输入: text=%{public}@, speakResponse=%{public}@", log: logger, type: .info, text, speakResponse ? "是" : "否") if !isInitialized { @@ -581,21 +581,21 @@ audioStream.saveAudioDataTo(data: audioData) } os_log("开始处理文本输入", log: logger, type: .info) - processWithChatApiService(text: text, speakResponse: speakResponse) + processWithChatApiService(sessionid: sessionid, text: text, speakResponse: speakResponse) return true } - private func processWithChatApiService(text: String, speakResponse: Bool = true) { + private func processWithChatApiService(sessionid:String,text: String, speakResponse: Bool = true) { guard let chatApiService = chatApiService else { sendError("ChatAPI服务未初始化", code: "CHATAPI_NOT_INITIALIZED") return } let userMessage = chatApiService.createUserMessage(content: text) - processWithChatApiServiceInternal(userMessage: userMessage, displayText: text, speakResponse: speakResponse) + processWithChatApiServiceInternal(sessionid: sessionid,userMessage: userMessage, displayText: text, speakResponse: speakResponse) } - private func processWithChatApiServiceInternal(userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false,imagePath:String = "") { + private func processWithChatApiServiceInternal(sessionid:String,userMessage: [String: Any], displayText: String, speakResponse: Bool = true, hasImage: Bool = false,imagePath:String = "") { os_log("开始处理ChatAPI请求: displayText=%{public}@, speakResponse=%{public}@, hasImage=%{public}@", log: logger, type: .info, displayText, speakResponse ? "是" : "否", hasImage ? "是" : "否") @@ -650,7 +650,7 @@ audioStream.saveAudioDataTo(data: audioData) hasImage: hasImage, imagePath: imagePath ) - currsessionId = UUID().uuidString + currsessionId = sessionid azureTtsHelper?.startspeak(sessionid: currsessionId) os_log("设置回调并调用sendMessageStream", log: logger, type: .info) chatApiService.setStreamCallback(currsessionId,callback) @@ -659,7 +659,7 @@ audioStream.saveAudioDataTo(data: audioData) os_log("sendMessageStream调用完成", log: logger, type: .info) } - func processImageInput(imagePath: String,imageUrl: String, text: String, speakResponse: Bool) -> Bool { + func processImageInput(sessionid: String,imagePath: String,imageUrl: String, text: String, speakResponse: Bool) -> Bool { if !isInitialized { sendError("服务未初始化", code: "NOT_INITIALIZED") return false @@ -703,6 +703,7 @@ audioStream.saveAudioDataTo(data: audioData) let displayText = text.isEmpty ? "[图片]" : text self.processWithChatApiServiceInternal( + sessionid: sessionid, userMessage: userMessage, displayText: displayText, speakResponse: speakResponse, @@ -731,7 +732,7 @@ audioStream.saveAudioDataTo(data: audioData) chatHistory.removeAll() // 获取最近10条消息 - let recentMessages = chatStorageHelper.getRecentMessages(sessionId: sessionId, limit: 10) + let recentMessages = chatStorageHelper.getRecentMessages(agentId: agentId, limit: 10) if recentMessages.isEmpty { return @@ -763,11 +764,12 @@ audioStream.saveAudioDataTo(data: audioData) /** * 保存聊天消息 */ - internal func saveChatMessage(userMessage: String, assistantMessage: String, metadata: String = "",userMetadata: String) { + internal func saveChatMessage(sessionId:String,userMessage: String, assistantMessage: String, metadata: String = "",userMetadata: String) { DispatchQueue.global(qos: .utility).async { // 保存用户消息 let userMessageId = self.chatStorageHelper.saveMessage( - sessionId: self.sessionId, + agentId: self.agentId, + sessionId: sessionId, message: userMessage, sender: "user", metadata: userMetadata @@ -777,7 +779,8 @@ audioStream.saveAudioDataTo(data: audioData) if !assistantMessage.isEmpty || !metadata.isEmpty { // 保存AI回复 let assistantMessageId = self.chatStorageHelper.saveMessage( - sessionId: self.sessionId, + agentId: self.agentId, + sessionId: sessionId, message: assistantMessage, sender: "assistant", metadata: metadata.isEmpty ? nil : metadata @@ -1348,7 +1351,7 @@ private func jsonToString(_ json: [String: Any]) -> String? { chatHistory.removeAll() DispatchQueue.global(qos: .utility).async { - let success = self.chatStorageHelper.deleteMessages(sessionId: self.sessionId, messageIds: nil as [Int]?) + let success = self.chatStorageHelper.deleteMessages(agentId:self.agentId, messageIds: nil as [Int]?) if !success { os_log("清除聊天历史失败", log: self.logger, type: .error) } @@ -1681,7 +1684,7 @@ class ChatApiStreamCallback: StreamCallback { try agentService.addToHistoryMessages(assistantMessage) } // 保存聊天记录 - try agentService.saveChatMessage(userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata)) + try agentService.saveChatMessage(sessionId: sessionId,userMessage: displayText, assistantMessage: response, metadata: metadata,userMetadata:toJSONString(userMetadata)) agentService.isAiStreaming = false os_log("设置AI流式状态为false", log: agentService.logger, type: .info) }catch{ @@ -2061,7 +2064,7 @@ class AudioPlayer { } extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { - func onResult(_ text: String, _ detectedLanguage: String) { + func onResult(sessionid:String ,_ text: String, _ detectedLanguage: String) { if !text.isEmpty && isKeepResult { var data: [String: Any] = ["text": text] if !detectedLanguage.isEmpty { @@ -2069,7 +2072,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { } sendEvent(name: "recognition_result", data: data) - processTextInput(text, speakResponse: true) + processTextInput(sessionid: sessionid,text, speakResponse: true) } let previousHasSpeech = hasSpeechDetected @@ -2080,7 +2083,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { } } - func onRecognizing(_ recognizing: String, _ detectedLanguage: String) { + func onRecognizing(sessionid:String ,_ recognizing: String, _ detectedLanguage: String) { if !recognizing.isEmpty && isKeepResult { let previousHasSpeech = hasSpeechDetected hasSpeechDetected = true @@ -2102,7 +2105,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { } } - func onSessionStarted() { + func onSessionStarted(sessionid:String) { sendEvent(name: "recognition_started", data: ["status": "started"]) isRecognizing = true hasSpeechDetected = false @@ -2123,7 +2126,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { } } - func onSessionStopped() { + func onSessionStopped(sessionid:String) { sendEvent(name: "recognition_stopped", data: ["status": "stopped"]) isRecognizing = false hasSpeechDetected = false @@ -2133,7 +2136,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { } } - func onCanceled(_ reason: String, _ errorDetails: String) { + func onCanceled(sessionid:String ,_ reason: String, _ errorDetails: String) { var data: [String: Any] = [:] if !reason.isEmpty { data["reason"] = reason @@ -2150,7 +2153,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { stopIdleCheck() } - func onError(_ errorCode: Int, _ error: String) { + func onError(sessionid:String ,_ errorCode: Int, _ error: String) { let data: [String: Any] = ["message": error.isEmpty ? "未知错误" : error] sendEvent(name: "error", data: data) isRecognizing = false diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift index e785c5192..08864a87f 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServicePlugin.swift @@ -104,17 +104,19 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { return case "processTextInput": guard let arguments = call.arguments as? [String: Any], + let sessionid = arguments["sessionid"] as? String, let text = arguments["text"] as? String else { result(FlutterError(code: "INVALID_ARGUMENTS", message: "文本不能为空", details: nil)) return } let speakResponse = arguments["speakResponse"] as? Bool ?? false - let success = impl.processTextInput(text, speakResponse: speakResponse) + let success = impl.processTextInput(sessionid: sessionid,text, speakResponse: speakResponse) result(success) case "processImageInput": guard let arguments = call.arguments as? [String: Any], + let sessionid = arguments["sessionid"] as? String, let imageUrl = arguments["imageUrl"] as? String, let imagePath = arguments["imagePath"] as? String else { result(FlutterError(code: "INVALID_ARGUMENTS", message: "图片路径不能为空", details: nil)) @@ -123,7 +125,7 @@ public class AgentServicePlugin: NSObject, FlutterPlugin { let text = arguments["text"] as? String ?? "" let speakResponse = arguments["speakResponse"] as? Bool ?? false - let success = impl.processImageInput(imagePath: imagePath,imageUrl: imageUrl, text: text, speakResponse: speakResponse) + let success = impl.processImageInput(sessionid: sessionid,imagePath: imagePath,imageUrl: imageUrl, text: text, speakResponse: speakResponse) result(success) case "speakText": diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index c2a051c5e..502c4c9f1 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -359,10 +359,11 @@ class AgentService { /// [text] 文本内容 /// [speakResponse] 是否朗读响应 /// 返回是否成功处理文本 - static Future processTextInput(String text, + static Future processTextInput(String sessionid, String text, {bool speakResponse = false}) async { try { final bool result = await _channel.invokeMethod('processTextInput', { + 'sessionid': sessionid, 'text': text, 'speakResponse': speakResponse, }); @@ -440,10 +441,12 @@ class AgentService { /// [imageUrl] 图片url /// [speakResponse] 是否朗读响应 /// 返回是否成功处理图片 - static Future processImageInput(String imagePath, String imageUrl, + static Future processImageInput( + String sessionid, String imagePath, String imageUrl, {String text = "", bool speakResponse = false}) async { try { final bool result = await _channel.invokeMethod('processImageInput', { + 'sessionid': sessionid, 'imagePath': imagePath, 'imageUrl': imageUrl, 'text': text, diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 630abbdf3..7d43d01c3 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -13,6 +13,7 @@ import java.util.concurrent.TimeUnit import com.yunqiinnovation.azure_speech.tools.RecordFile import com.microsoft.cognitiveservices.speech.audio.AudioConfig import com.yunqiinnovation.azure_speech.tools.AudioRecordingForegroundService +import java.util.UUID /** @@ -58,7 +59,7 @@ class AzureAsrHelper(private val context: Context) { // 防抖机制相关变量 private var lastNetworkLostTime = 0L private val networkLostDebounceInterval = 3000L // 2秒防抖间隔 - + private var currsessionid = "" //识别回话id 关联到整个聊天过程中 /** @@ -242,7 +243,7 @@ class AzureAsrHelper(private val context: Context) { // 检查网络状态 if (!checkNetworkStatus()) { Log.e(tag, "网络不可用,无法开始语音识别") - continuousCallback?.onError(1000,"网络连接不可用,请检查网络设置") + continuousCallback?.onError(currsessionid,1000,"网络连接不可用,请检查网络设置") return false } @@ -258,7 +259,7 @@ class AzureAsrHelper(private val context: Context) { Log.d(tag, "前台服务已启动") } catch (e: Exception) { Log.e(tag, "启动前台服务失败: ${e.message}") - continuousCallback?.onError(1003, "启动前台服务失败: ${e.message}") + continuousCallback?.onError(currsessionid,1003, "启动前台服务失败: ${e.message}") return false } } @@ -268,7 +269,7 @@ class AzureAsrHelper(private val context: Context) { val callback = continuousCallback if (callback == null) { Log.e(tag, "无法重新启动连续识别:回调为空") - continuousCallback?.onError(1001,"回调为空") + continuousCallback?.onError(currsessionid,1001,"回调为空") return false } setupEventListeners(callback) @@ -308,7 +309,7 @@ class AzureAsrHelper(private val context: Context) { fun setupEventListeners(callback: ContinuousRecognizeCallback): Boolean { Log.d(tag, "设置ssssss监听器:${speechConfig ?: "null"} ") if (speechConfig == null) { - callback.onError(1002,"语音服务未初始化") + callback.onError(currsessionid,1002,"语音服务未初始化") return false } @@ -330,7 +331,7 @@ class AzureAsrHelper(private val context: Context) { } else { currentLanguage } - callback.onRecognizing(event.result.text, detectedLanguage) + callback.onRecognizing(currsessionid,event.result.text, detectedLanguage) } } ) @@ -345,7 +346,7 @@ class AzureAsrHelper(private val context: Context) { } else { currentLanguage } - callback.onResult(event.result.text, detectedLanguage) + callback.onResult(currsessionid,event.result.text, detectedLanguage) } } ) @@ -355,7 +356,8 @@ class AzureAsrHelper(private val context: Context) { EventHandler { _, _ -> // 直接在当前线程调用回调 Log.d(tag, "会话开始事件") - callback.onSessionStarted() + currsessionid = UUID.randomUUID().toString() + callback.onSessionStarted(currsessionid) } ) @@ -365,7 +367,7 @@ class AzureAsrHelper(private val context: Context) { // 直接在当前线程调用回调 Log.d(tag, "会话结束事件") if (audioSourceType == AudioSourceType.EXTERNAL) { - callback.onSessionStopped() + callback.onSessionStopped(currsessionid) audioStream?.isContinuousRecognitionActive = false } } @@ -378,11 +380,11 @@ class AzureAsrHelper(private val context: Context) { val reason = event.reason.toString() Log.d(tag, "识别被取消: reason=$reason, details=$errorDetails") - callback.onCanceled(reason,errorDetails) + callback.onCanceled(currsessionid,reason,errorDetails) } ) } catch (e: Exception) { - callback.onError(1001,"启动连续识别失败: ${e.message}") + callback.onError(currsessionid,1001,"启动连续识别失败: ${e.message}") return false } return true @@ -495,7 +497,7 @@ class AzureAsrHelper(private val context: Context) { // 确保状态被重置 audioStream.isContinuousRecognitionActive = false - + currsessionid = "" } catch (e: Exception) { // 确保状态被重置 audioStream.isContinuousRecognitionActive = false @@ -503,6 +505,7 @@ class AzureAsrHelper(private val context: Context) { recognizer = null speechConfig = null isForegroundServiceRunning = false + currsessionid = "" } } @@ -689,7 +692,7 @@ class AzureAsrHelper(private val context: Context) { * @param text 识别的文本 * @param detectedLanguage 检测到的语言 */ - fun onResult(text: String, detectedLanguage: String) + fun onResult(sessiond:String, text: String, detectedLanguage: String) /** * 识别进行中调用 @@ -697,17 +700,17 @@ class AzureAsrHelper(private val context: Context) { * @param recognizing 正在识别的文本 * @param detectedLanguage 检测到的语言 */ - fun onRecognizing(recognizing: String, detectedLanguage: String) + fun onRecognizing(sessiond:String, recognizing: String, detectedLanguage: String) /** * 会话开始时调用 */ - fun onSessionStarted() + fun onSessionStarted(sessiond:String) /** * 会话结束时调用 */ - fun onSessionStopped() + fun onSessionStopped(sessiond:String) /** @@ -716,14 +719,14 @@ class AzureAsrHelper(private val context: Context) { * @param reason 取消原因 * @param errorDetails 错误详情 */ - fun onCanceled(reason: String, errorDetails: String) + fun onCanceled(sessiond:String,reason: String, errorDetails: String) /** * 识别出错时调用 * * @param error 错误信息 */ - fun onError(code: Int,error: String) + fun onError(sessiond:String, code: Int,error: String) } @@ -781,7 +784,7 @@ class AzureAsrHelper(private val context: Context) { lastNetworkLostTime = 0L isNetworkRecovering = true } else { - continuousCallback?.onError(1000,"网络连接不可用,请检查网络设置") + continuousCallback?.onError(currsessionid,1000,"网络连接不可用,请检查网络设置") Log.d(tag,"网络仍未恢复,继续检测") networkLostCount = 0 stopAudioProcessingImmediately() diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 1046f1979..ca31912cf 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -47,6 +47,7 @@ public class AzureAsrHelper: NSObject { private var isAudioStarted = false private var pendingStopRequest = false + private var currsessionId = "" // 配置参数 @@ -197,7 +198,7 @@ public class AzureAsrHelper: NSObject { self.lastNetworkLostTime = 0 self.isNetworkRecovering = true } else { - self.continuousCallback?.onError(1000, "网络连接不可用,请检查网络设置") + self.continuousCallback?.onError(sessionid: currsessionId,1000, "网络连接不可用,请检查网络设置") os_log("网络仍未恢复,继续检测", log: self.log, type: .debug) self.networkLostCount = 0 self.stopContinuousRecognition() @@ -780,7 +781,7 @@ public class AzureAsrHelper: NSObject { print("正在识别事件=检测到语言: \(detectedLanguage), 识别中: \(text)") DispatchQueue.main.async { - callback.onRecognizing(result.text ?? "", detectedLanguage) + callback.onRecognizing(sessionid: self.currsessionId,result.text ?? "", detectedLanguage) } } @@ -799,14 +800,15 @@ public class AzureAsrHelper: NSObject { // 在主线程回调结果 DispatchQueue.main.async { - callback.onResult(result.text ?? "", detectedLanguage) + callback.onResult(sessionid: self.currsessionId,result.text ?? "", detectedLanguage) } } // 会话开始事件 recognizer.addSessionStartedEventHandler { (sender, event) in // 直接在当前线程调用回调 - callback.onSessionStarted() + self.currsessionId = UUID().uuidString + callback.onSessionStarted(sessionid: self.currsessionId) print("会话开始事件:") } @@ -815,7 +817,7 @@ public class AzureAsrHelper: NSObject { guard let self = self else { return } print("会话结束事件:") // 直接在当前线程调用回调 - callback.onSessionStopped() + callback.onSessionStopped(sessionid: self.currsessionId) self.audioStream?.isContinuousRecognitionActive = false //self.stopAudioProcessing() } @@ -830,7 +832,7 @@ public class AzureAsrHelper: NSObject { os_log("识别取消: %{public}@", log: self.log, type: .error, errorDetails) - callback.onCanceled(reason, errorDetails) + callback.onCanceled(sessionid: self.currsessionId,reason, errorDetails) } return true @@ -994,7 +996,7 @@ public class AzureAsrHelper: NSObject { * @param text 识别的文本 * @param detectedLanguage 检测到的语言 */ - func onResult(_ text: String, _ detectedLanguage: String) + func onResult(sessionid:String,_ text: String, _ detectedLanguage: String) /** * 识别进行中调用 @@ -1002,17 +1004,17 @@ public class AzureAsrHelper: NSObject { * @param recognizing 正在识别的文本 * @param detectedLanguage 检测到的语言 */ - func onRecognizing(_ recognizing: String, _ detectedLanguage: String) + func onRecognizing(sessionid:String ,_ recognizing: String, _ detectedLanguage: String) /** * 会话开始时调用 */ - func onSessionStarted() + func onSessionStarted(sessionid:String) /** * 会话结束时调用 */ - func onSessionStopped() + func onSessionStopped(sessionid:String) /** * 识别取消时调用 @@ -1020,14 +1022,14 @@ public class AzureAsrHelper: NSObject { * @param reason 取消原因 * @param errorDetails 错误详情 */ - func onCanceled(_ reason: String, _ errorDetails: String) + func onCanceled(sessionid:String ,_ reason: String, _ errorDetails: String) /** * 识别出错时调用 * * @param error 错误信息 */ - func onError(_ code: Int,_ error: String) + func onError(sessionid:String ,_ code: Int,_ error: String) } /** * 检查网络状态 @@ -1039,7 +1041,7 @@ private func checkNetworkStatus() -> Bool { if !isAvailable { os_log("网络不可用,无法启动语音识别", log: log, type: .error) - continuousCallback?.onError(1000,"网络连接不可用,请检查网络设置") + continuousCallback?.onError(sessionid: self.currsessionId,1000,"网络连接不可用,请检查网络设置") } return isAvailable diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 4a272ea12..05a6424d1 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -1208,7 +1208,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { * @param text 识别的文本 * @param detectedLanguage 检测到的语言 */ - func onResult(_ text: String, _ detectedLanguage: String) { + func onResult(sessionid:String,_ text: String, _ detectedLanguage: String) { plugin?.sendAsrEvent([ "type": "result", "text": text, @@ -1221,7 +1221,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { * @param text 正在识别的文本 * @param detectedLanguage 检测到的语言 */ - func onRecognizing(_ text: String, _ detectedLanguage: String) { + func onRecognizing(sessionid:String,_ text: String, _ detectedLanguage: String) { plugin?.sendAsrEvent([ "type": "recognizing", "text": text, @@ -1232,7 +1232,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { /** * 会话开始回调 */ - func onSessionStarted() { + func onSessionStarted(sessionid:String) { plugin?.sendAsrEvent([ "type": "sessionStarted" ]) @@ -1241,7 +1241,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { /** * 会话结束回调 */ - func onSessionStopped() { + func onSessionStopped(sessionid:String) { plugin?.sendAsrEvent([ "type": "sessionStopped" ]) @@ -1252,7 +1252,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { * @param reason 取消原因 * @param errorDetails 错误详情 */ - func onCanceled(_ reason: String, _ errorDetails: String) { + func onCanceled(sessionid:String,_ reason: String, _ errorDetails: String) { plugin?.sendAsrEvent([ "type": "canceled", "reason": reason, @@ -1265,7 +1265,7 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { * @param errorCode 错误码 * @param error 错误信息 */ - func onError(_ errorCode: Int, _ error: String) { + func onError(sessionid:String,_ errorCode: Int, _ error: String) { plugin?.sendAsrEvent([ "type": "error", "code": errorCode, diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt index d4a792234..6ab4db783 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt @@ -164,16 +164,18 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor val oldSessionId = currSessionId // 1. 取消当前的协程任务 - currentStreamJob?.cancel() + currentStreamJob?.cancel(CancellationException("New session started")) currentStreamJob = null // 2. 通知旧会话被中止 - getSessionCallback(oldSessionId)?.onComplete(currSessionId) // 直接完成当前会话 + getSessionCallback(oldSessionId)?.onComplete(oldSessionId) // 直接完成当前会话 // 3. 清理状态 currSessionId = "" sessionCallbacks.remove(oldSessionId) toolCalls.clear() + + Log.d(TAG, "会话 $oldSessionId 已被中止") } } @@ -288,7 +290,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // 检查是否包含图片,决定使用哪个模型 val currentModel = if (containsImage(messages)) visionModel else model - + // 转换消息格式 val chatMessages = convertToChatMessages(messages) @@ -352,10 +354,10 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor if (!isInitialized || apiKey.isEmpty() || openAI == null) { Log.e("ChatApiService", "ChatAPI服务未初始化,无法发送消息") try { - getSessionCallback(sessionid)?.onError(sessionid,ChatApiException("ChatAPI服务未初始化")) - } catch (e: Exception) { - Log.e(TAG, "onError回调异常: ${e.message}", e) - } + getSessionCallback(sessionid)?.onError(sessionid,ChatApiException("ChatAPI服务未初始化")) + } catch (e: Exception) { + Log.e(TAG, "onError回调异常: ${e.message}", e) + } return } @@ -369,6 +371,8 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor try { // 转换消息格式 val chatMessages = try { + // ✨ 第一步:上下文精简 +// val reducedMessages = reduce(messages) convertToChatMessages(messages) } catch (e: Exception) { Log.e("ChatApiService", "转换消息格式失败: ${e.message}", e) @@ -425,12 +429,10 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } var replyContext = ""; + // 为每个会话新增一个状态标记:是否为中间工具调用(默认false) + var isIntermediateToolCall = false // Log.d(TAG, "liwei-------------------------开始AI 对话 7") chatsFlow.collect { result -> -// val usage = result.usage -// if (usage != null){ //发现流式不返回这歌数据 -// getSessionCallback(sessionid)?.onUsage(sessionid,usage.promptTokens,usage.completionTokens,usage.totalTokens) -// } // 每次处理前检查是否仍是当前会话 if (sessionid != currSessionId) { // throw CancellationException("Session cancelled") @@ -448,8 +450,22 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor Log.w("ChatApiService", "[Session: $sessionid] delta为null") return@collect } - // Log.d(TAG, "liwei-------------------------开始AI 对话 7-3") - if (delta.toolCalls == null){ + + + // -------------------------- + // 核心修改:第一帧判断是否为工具调用 + // -------------------------- + if (!isIntermediateToolCall) { + // 第一帧(或未标记时):检查是否有工具调用字段 + val hasToolCalls = delta.toolCalls?.isNotEmpty() == true + val hasFunctionCall = delta.functionCall != null + if (hasToolCalls || hasFunctionCall) { + // 标记为中间工具调用,后续所有帧都拦截 + isIntermediateToolCall = true + } + } + //中间过程不输出 + if (!isIntermediateToolCall){ // 处理普通文本内容 delta.content?.let { content -> try { @@ -460,7 +476,15 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // 不重新抛出异常,继续处理后续内容 } } - } + } + //else{ + // delta.content?.let { content -> + // Log.d("ChatApiService", "liwei------------ [Session: $sessionid] 中间过程不输出 $content") + // } + + // } + + // Log.d(TAG, "liwei-------------------------开始AI 对话 7-4") // 收集工具调用信息 delta.toolCalls?.forEach { toolCall -> @@ -577,113 +601,131 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor "arguments" to firstToolCall.arguments, "id" to firstToolCall.id ) + // 通知上层工具调用事件 try { getSessionCallback(sessionid)?.onFunctionCall(sessionid,convertMapToJsonObject(functionCall)) } catch (e: Exception) { Log.e(TAG, "onFunctionCall回调异常: ${e.message}", e) } + // 在当前协程作用域内处理工具调用,使用async确保生命周期管理 val toolCallDeferred = async { try { - if (sessionid == currSessionId) { - // 通过MCP客户端处理工具调用 - val functionName = firstToolCall.name - val argumentsJson = firstToolCall.arguments - var isError = false - var errorText = "" - val result = if (_mcpClient?.hasToolWithName(functionName) == true) { - // 解析参数,如果参数为空JSON对象,使用空Map - val arguments = if (argumentsJson == "{}") { - emptyMap() - } else { - _mcpClient?.parseJsonArguments(argumentsJson) ?: emptyMap() - } - // 调用MCP工具 - val toolResult = try { - withTimeout(60000) { // 60秒超时 - _mcpClient?.callTool(functionName, arguments) + // 检查会话是否仍然有效 + if (sessionid != currSessionId) { + Log.d(TAG, "[Session: $sessionid] 会话已被替换,跳过工具调用") + return@async + } + + // 通过MCP客户端处理工具调用 + val functionName = firstToolCall.name + val argumentsJson = firstToolCall.arguments + var isError = false + var errorText = "" + val result = if (_mcpClient?.hasToolWithName(functionName) == true) { + // 解析参数,如果参数为空JSON对象,使用空Map + val arguments = if (argumentsJson == "{}") { + emptyMap() + } else { + _mcpClient?.parseJsonArguments(argumentsJson) ?: emptyMap() + } + // 调用MCP工具 + val toolResult = try { + withTimeout(60000) { // 60秒超时 + // 再次检查会话状态 + if (sessionid != currSessionId) { + throw CancellationException("Session cancelled") } - } catch (e: TimeoutCancellationException) { - Log.w("ChatApiService", "[Session: $sessionid] MCP工具调用超时: $functionName") - mapOf( - "content" to listOf(mapOf( - "type" to "text", - "text" to "Tool call timeout: $functionName" - )), - "isError" to true - ) - } catch (e: Exception) { - Log.e("ChatApiService", "[Session: $sessionid] MCP工具调用异常: $functionName", e) - mapOf( - "content" to listOf(mapOf( - "type" to "text", - "text" to "Tool call failed: ${e.message}" - )), - "isError" to true - ) + _mcpClient?.callTool(functionName, arguments) } - Log.d("ChatApiService", "[Session: $sessionid] AI调用工具结果: $functionName, 参数: $arguments, 结果: $toolResult") - // 处理结果 - if (toolResult != null) { - if (toolResult["isError"] == true) { - // 处理错误情况 - isError = true - val content = toolResult["content"] as? List<*> - val firstContent = content?.firstOrNull() as? Map<*, *> - errorText = firstContent?.get("text") as? String ?: "Tool execution failed" - mapOf("context" to errorText) - } else if (toolResult.containsKey("context")) { - // 本地函数结果 - toolResult - } else { - // MCP工具结果 - val content = toolResult["content"] as? List<*> - val firstContent = content?.firstOrNull() as? Map<*, *> - val text = firstContent?.get("text") as? String ?: "" - mapOf("context" to text) - } + } catch (e: TimeoutCancellationException) { + Log.w("ChatApiService", "[Session: $sessionid] MCP工具调用超时: $functionName") + mapOf( + "content" to listOf(mapOf( + "type" to "text", + "text" to "Tool call timeout: $functionName" + )), + "isError" to true + ) + } catch (e: CancellationException) { + Log.d("ChatApiService", "[Session: $sessionid] MCP工具调用被取消: $functionName") + // 不抛出异常,直接返回取消结果 + mapOf( + "content" to listOf(mapOf( + "type" to "text", + "text" to "Tool call cancelled" + )), + "isError" to true + ) + } catch (e: Exception) { + Log.e("ChatApiService", "[Session: $sessionid] MCP工具调用异常: $functionName", e) + mapOf( + "content" to listOf(mapOf( + "type" to "text", + "text" to "Tool call failed: ${e.message}" + )), + "isError" to true + ) + } + Log.d("ChatApiService", "[Session: $sessionid] AI调用工具结果: $functionName, 参数: $arguments, 结果: $toolResult") + // 处理结果 + if (toolResult != null) { + if (toolResult["isError"] == true) { + // 处理错误情况 + isError = true + val content = toolResult["content"] as? List<*> + val firstContent = content?.firstOrNull() as? Map<*, *> + errorText = firstContent?.get("text") as? String ?: "Tool execution failed" + mapOf("context" to errorText) + + } else if (toolResult.containsKey("context")) { + // 本地函数结果 + toolResult } else { - Log.w("ChatApiService", "[Session: $sessionid] MCP工具调用返回null") - mapOf("context" to "Tool call failed") + // MCP工具结果 + val content = toolResult["content"] as? List<*> + val firstContent = content?.firstOrNull() as? Map<*, *> + val text = firstContent?.get("text") as? String ?: "" + mapOf("context" to text) } } else { - // 工具不存在 - mapOf("context" to "Tool not found: $functionName") + Log.w("ChatApiService", "[Session: $sessionid] MCP工具调用返回null") + mapOf("context" to "Tool call failed") } - if (sessionid == currSessionId) { -// if (functionName != "set_user_profile_field"){ - // 处理结果 - try { - if(!isError){ - getSessionCallback(sessionid)?.onFunctionCallResult( - sessionid, - convertMapToJsonObject(functionCall), - convertMapToJsonObject(result) - ) - }else{ - getSessionCallback(sessionid)?.onError(sessionid, ChatApiException(errorText)) - } - } catch (e: Exception) { - Log.e(TAG, "onFunctionCallResult回调异常: ${e.message}", e) - } - // 将结果发送回OpenAI继续对话 - sendFunctionCallResultInternal( - sessionid= sessionid, - messages = currentMessages, - functionCall = functionCall, - functionResult = gson.toJson(result) + } else { + // 工具不存在 + mapOf("context" to "Tool not found: $functionName") + } + + // 最后检查会话状态 + if (sessionid == currSessionId) { + try { + if(!isError){ + getSessionCallback(sessionid)?.onFunctionCallResult( + sessionid, + convertMapToJsonObject(functionCall), + convertMapToJsonObject(result) ) -// }else{ -// try { -// getSessionCallback(sessionid)?.onComplete(sessionid) -// } catch (ex: Exception) { -// Log.e(TAG, "onComplete回调异常: ${ex.message}", ex) -// } -// clearCurrentSession() -// } + }else{ + getSessionCallback(sessionid)?.onError(sessionid, ChatApiException(errorText)) + } + } catch (e: Exception) { + Log.e(TAG, "onFunctionCallResult回调异常: ${e.message}", e) } + // 将结果发送回OpenAI继续对话 + sendFunctionCallResultInternal( + sessionid= sessionid, + messages = currentMessages, + functionCall = functionCall, + functionResult = gson.toJson(result) + ) + } else { + Log.d(TAG, "[Session: $sessionid] 工具调用完成时会话已被替换,跳过结果处理") } + } catch (e: CancellationException) { + Log.d("ChatApiService", "[Session: $sessionid] 工具调用协程被取消") + // 不重新抛出CancellationException,避免传播 } catch (e: Exception) { Log.e("ChatApiService", "工具调用处理过程中出错: ${e.message}", e) if (sessionid == currSessionId) { @@ -702,9 +744,10 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor try { toolCallDeferred.await() } catch (e: CancellationException) { + Log.d(TAG, "[Session: $sessionid] 工具调用被取消") // 协程被取消,确保子任务也被取消 toolCallDeferred.cancel() - throw e + // 不重新抛出异常,让流程正常结束 } return true @@ -1045,7 +1088,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } return false } - + /** * 转换为OpenAI ChatMessage格式 */ diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt index d13fbc37a..b515dedb2 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/CustomSseClientTransport.kt @@ -30,9 +30,10 @@ class CustomSseClientTransport( ) : AbstractTransport() { private val TAG = "CustomSseClientTransport" - private val scope by lazy { - CoroutineScope(session.coroutineContext + SupervisorJob()) - } + private val transportScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // ✅ 为每个会话创建独立的作用域 + private var sessionScope: CoroutineScope? = null private val initialized = AtomicBoolean(false) private var session: ClientSSESession by Delegates.notNull() @@ -98,7 +99,7 @@ class CustomSseClientTransport( * 收集SSE事件 */ private suspend fun collectEvents() { - job = scope.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { + job = sessionScope?.launch(CoroutineName("CustomSseMcpClientTransport.collect#${hashCode()}")) { try { session.incoming.collect { event -> Log.e(TAG, "$serviceidString event:${event.event}-${event.data}") @@ -238,7 +239,7 @@ class CustomSseClientTransport( * 启动连接监控,定期检查连接状态 */ private fun startConnectionMonitor() { - connectionMonitorJob = scope.launch { + connectionMonitorJob = transportScope.launch { while (isActive && isConnected.get()) { try { delay(10000) // 每10秒检查一次 @@ -278,6 +279,8 @@ class CustomSseClientTransport( queryParams = urlInfo.third } + sessionScope = CoroutineScope(transportScope.coroutineContext + SupervisorJob()) + // 创建SSE会话 - 直接使用原始URL session = urlString?.let { val sseConnectUrl = if (queryParams.isNotEmpty()) { @@ -381,7 +384,8 @@ class CustomSseClientTransport( _onClose() job?.cancelAndJoin() connectionMonitorJob?.cancelAndJoin() - + sessionScope?.cancel() + transportScope.cancel() Log.d(TAG, "$serviceidString CustomSseClientTransport已关闭") } } \ No newline at end of file diff --git a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt index 930662395..15def6f51 100644 --- a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt +++ b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStorageHelper.kt @@ -22,13 +22,14 @@ class ChatStorageHelper(context: Context) { /** * 保存消息 * - * @param sessionId 会话ID + * @param agentId 会话ID * @param message 消息内容 * @param sender 发送者("user" 或 "assistant") * @param metadata 元数据(可选) * @return 成功返回消息ID,失败返回-1 */ fun saveMessage( + agentId: String, sessionId: String, message: String, sender: String, @@ -42,6 +43,7 @@ class ChatStorageHelper(context: Context) { // 执行插入操作 val values = android.content.ContentValues().apply { + put(ChatDatabaseHelper.COLUMN_AGENT_ID, agentId) put(ChatDatabaseHelper.COLUMN_SESSION_ID, sessionId) put(ChatDatabaseHelper.COLUMN_MESSAGE, message) put(ChatDatabaseHelper.COLUMN_SENDER, sender) @@ -59,12 +61,12 @@ class ChatStorageHelper(context: Context) { /** * 获取指定会话的消息 * - * @param sessionId 会话ID + * @param agentId 会话ID * @param page 页码,从1开始 * @param pageSize 每页大小 * @return 包含消息列表和分页信息的JSON对象 */ - fun getMessages(sessionId: String, page: Int = 1, pageSize: Int = 20): JSONObject { + fun getMessages(agentId: String, page: Int = 1, pageSize: Int = 20): JSONObject { try { val db = dbHelper.readableDatabase val offset = (page - 1) * pageSize @@ -72,19 +74,19 @@ class ChatStorageHelper(context: Context) { // 构建查询语句 val query = """ SELECT * FROM ${ChatDatabaseHelper.TABLE_MESSAGES} - WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ? + WHERE ${ChatDatabaseHelper.COLUMN_AGENT_ID} = ? ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC LIMIT ? OFFSET ? """.trimIndent() - val selectionArgs = arrayOf(sessionId, pageSize.toString(), offset.toString()) + val selectionArgs = arrayOf(agentId, pageSize.toString(), offset.toString()) val cursor = db.rawQuery(query, selectionArgs) val messages = cursorToJsonArray(cursor) // 获取总记录数 - val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?" - val countCursor = db.rawQuery(countQuery, arrayOf(sessionId)) + val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_AGENT_ID} = ?" + val countCursor = db.rawQuery(countQuery, arrayOf(agentId)) var totalCount = 0 if (countCursor.moveToFirst()) { totalCount = countCursor.getInt(0) @@ -125,24 +127,26 @@ class ChatStorageHelper(context: Context) { // 查询不同的会话ID及其最新消息 val query = """ - SELECT s1.${ChatDatabaseHelper.COLUMN_SESSION_ID}, + SELECT s1.${ChatDatabaseHelper.COLUMN_AGENT_ID}, + s1.${ChatDatabaseHelper.COLUMN_SESSION_ID}, s1.${ChatDatabaseHelper.COLUMN_MESSAGE}, s1.${ChatDatabaseHelper.COLUMN_SENDER}, s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} FROM ${ChatDatabaseHelper.TABLE_MESSAGES} s1 INNER JOIN ( - SELECT ${ChatDatabaseHelper.COLUMN_SESSION_ID}, + SELECT ${ChatDatabaseHelper.COLUMN_AGENT_ID}, MAX(${ChatDatabaseHelper.COLUMN_TIMESTAMP}) as max_timestamp FROM ${ChatDatabaseHelper.TABLE_MESSAGES} - GROUP BY ${ChatDatabaseHelper.COLUMN_SESSION_ID} - ) s2 ON s1.${ChatDatabaseHelper.COLUMN_SESSION_ID} = s2.${ChatDatabaseHelper.COLUMN_SESSION_ID} + GROUP BY ${ChatDatabaseHelper.COLUMN_AGENT_ID} + ) s2 ON s1.${ChatDatabaseHelper.COLUMN_AGENT_ID} = s2.${ChatDatabaseHelper.COLUMN_AGENT_ID} AND s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} = s2.max_timestamp ORDER BY s1.${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC """.trimIndent() val cursor = db.rawQuery(query, null) val sessions = JSONArray() - + + val agentIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_AGENT_ID) val sessionIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_ID) val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE) val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER) @@ -150,13 +154,14 @@ class ChatStorageHelper(context: Context) { while (cursor.moveToNext()) { val session = JSONObject().apply { + put("agentId", cursor.getString(agentIdIndex)) put("sessionId", cursor.getString(sessionIdIndex)) put("lastMessage", cursor.getString(messageIndex)) put("lastSender", cursor.getString(senderIndex)) put("timestamp", cursor.getString(timestampIndex)) // 获取会话中的消息数量 - val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?" + val countQuery = "SELECT COUNT(*) FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_AGENT_ID} = ?" val countCursor = db.rawQuery(countQuery, arrayOf(cursor.getString(sessionIdIndex))) if (countCursor.moveToFirst()) { put("messageCount", countCursor.getInt(0)) @@ -181,7 +186,7 @@ class ChatStorageHelper(context: Context) { * @param messageIds 消息ID列表(可选,如果提供则删除指定消息) * @return 成功返回true,失败返回false */ - fun deleteMessages(sessionId: String? = null, messageIds: List? = null): Boolean { + fun deleteMessages(agentId: String? = null, messageIds: List? = null): Boolean { try { val db = dbHelper.writableDatabase @@ -191,10 +196,10 @@ class ChatStorageHelper(context: Context) { val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_ID} IN ($idsStr)" db.execSQL(deleteQuery) return true - } else if (!sessionId.isNullOrEmpty()) { + } else if (!agentId.isNullOrEmpty()) { // 删除会话中的所有消息 - val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ?" - db.execSQL(deleteQuery, arrayOf(sessionId)) + val deleteQuery = "DELETE FROM ${ChatDatabaseHelper.TABLE_MESSAGES} WHERE ${ChatDatabaseHelper.COLUMN_AGENT_ID} = ?" + db.execSQL(deleteQuery, arrayOf(agentId)) return true } return false @@ -223,11 +228,11 @@ class ChatStorageHelper(context: Context) { /** * 查询指定会话的最近N条消息 * - * @param sessionId 会话ID + * @param agentId 会话ID * @param limit 限制数量 * @return 消息列表,按时间正序排列 */ - fun getRecentMessages(sessionId: String, limit: Int): List> { + fun getRecentMessages(agentId: String, limit: Int): List> { try { val db = dbHelper.readableDatabase @@ -235,13 +240,13 @@ class ChatStorageHelper(context: Context) { val query = """ SELECT * FROM ( SELECT * FROM ${ChatDatabaseHelper.TABLE_MESSAGES} - WHERE ${ChatDatabaseHelper.COLUMN_SESSION_ID} = ? + WHERE ${ChatDatabaseHelper.COLUMN_AGENT_ID} = ? ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} DESC LIMIT ? ) tmp ORDER BY ${ChatDatabaseHelper.COLUMN_TIMESTAMP} ASC """.trimIndent() - val selectionArgs = arrayOf(sessionId, limit.toString()) + val selectionArgs = arrayOf(agentId, limit.toString()) val cursor = db.rawQuery(query, selectionArgs) val messages = ArrayList>() @@ -282,6 +287,7 @@ class ChatStorageHelper(context: Context) { val jsonArray = JSONArray() val idIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_ID) + val agentIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_AGENT_ID) val sessionIdIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_ID) val messageIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MESSAGE) val senderIndex = cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SENDER) @@ -291,6 +297,7 @@ class ChatStorageHelper(context: Context) { while (cursor.moveToNext()) { val jsonObject = JSONObject().apply { put("id", cursor.getInt(idIndex)) + put("agentId", cursor.getString(agentIdIndex)) put("sessionId", cursor.getString(sessionIdIndex)) put("message", cursor.getString(messageIndex)) put("sender", cursor.getString(senderIndex)) @@ -328,6 +335,7 @@ class ChatDatabaseHelper(context: Context) : // 定义消息表 const val TABLE_MESSAGES = "messages" const val COLUMN_ID = "id" + const val COLUMN_AGENT_ID = "agent_id" const val COLUMN_SESSION_ID = "session_id" const val COLUMN_MESSAGE = "message" const val COLUMN_SENDER = "sender" @@ -340,6 +348,7 @@ class ChatDatabaseHelper(context: Context) : val createMessagesTable = """ CREATE TABLE $TABLE_MESSAGES ( $COLUMN_ID INTEGER PRIMARY KEY AUTOINCREMENT, + $COLUMN_AGENT_ID TEXT NOT NULL, $COLUMN_SESSION_ID TEXT NOT NULL, $COLUMN_MESSAGE TEXT NOT NULL, $COLUMN_SENDER TEXT NOT NULL, @@ -351,6 +360,7 @@ class ChatDatabaseHelper(context: Context) : db.execSQL(createMessagesTable) // 创建索引以加快查询速度 + db.execSQL("CREATE INDEX idx_agent_id ON $TABLE_MESSAGES ($COLUMN_AGENT_ID)") db.execSQL("CREATE INDEX idx_session_id ON $TABLE_MESSAGES ($COLUMN_SESSION_ID)") db.execSQL("CREATE INDEX idx_timestamp ON $TABLE_MESSAGES ($COLUMN_TIMESTAMP)") } diff --git a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt index 91b610d80..bed32b4da 100644 --- a/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt +++ b/local_plugins/chat_storage/android/src/main/kotlin/com/yunqiinnovation/chat_storage/ChatStoragePlugin.kt @@ -31,12 +31,14 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { when (call.method) { "saveMessage" -> { try { + val agentId = call.argument("agentId") ?: "" val sessionId = call.argument("sessionId") ?: "" val message = call.argument("message") ?: "" val sender = call.argument("sender") ?: "" val metadata = call.argument("metadata") val id = chatStorageHelper.saveMessage( + agentId = agentId, sessionId = sessionId, message = message, sender = sender, @@ -55,12 +57,12 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { } "getMessages" -> { try { - val sessionId = call.argument("sessionId") ?: "" + val agentId = call.argument("agentId") ?: "" val page = call.argument("page") ?: 1 val pageSize = call.argument("pageSize") ?: 20 val messagesJson = chatStorageHelper.getMessages( - sessionId = sessionId, + agentId = agentId, page = page, pageSize = pageSize ) @@ -82,11 +84,11 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { } "deleteMessages" -> { try { - val sessionId = call.argument("sessionId") + val agentId = call.argument("agentId") val messageIds = call.argument>("messageIds") val success = chatStorageHelper.deleteMessages( - sessionId = sessionId, + agentId = agentId, messageIds = messageIds ) @@ -111,11 +113,11 @@ class ChatStoragePlugin: FlutterPlugin, MethodCallHandler { } "getRecentMessages" -> { try { - val sessionId = call.argument("sessionId") ?: "" + val agentId = call.argument("agentId") ?: "" val limit = call.argument("limit") ?: 10 val messages = chatStorageHelper.getRecentMessages( - sessionId = sessionId, + agentId = agentId, limit = limit ) diff --git a/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/ChatStorageHelper.swift b/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/ChatStorageHelper.swift index 96fa34a8e..acf2b4588 100644 --- a/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/ChatStorageHelper.swift +++ b/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/ChatStorageHelper.swift @@ -34,12 +34,14 @@ public class ChatStorageHelper { let createTableString = """ CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, session_id TEXT NOT NULL, message TEXT NOT NULL, sender TEXT NOT NULL, metadata TEXT, created_at INTEGER NOT NULL ); + CREATE INDEX IF NOT EXISTS idx_agent_id ON messages (session_id); CREATE INDEX IF NOT EXISTS idx_session_id ON messages (session_id); CREATE INDEX IF NOT EXISTS idx_created_at ON messages (created_at); """ @@ -64,11 +66,12 @@ public class ChatStorageHelper { * @param metadata 可选的元数据 * @return 插入的消息ID,失败则返回-1 */ - public func saveMessage(sessionId: String, message: String, sender: String, metadata: String?) -> Int64 { - let insertStatementString = "INSERT INTO messages (session_id, message, sender, metadata, created_at) VALUES (?, ?, ?, ?, ?);" + public func saveMessage(agentId:String,sessionId: String, message: String, sender: String, metadata: String?) -> Int64 { + let insertStatementString = "INSERT INTO messages (agent_id,session_id, message, sender, metadata, created_at) VALUES (?, ?, ?, ?, ?, ?);" var insertStatement: OpaquePointer? if sqlite3_prepare_v2(db, insertStatementString, -1, &insertStatement, nil) == SQLITE_OK { + sqlite3_bind_text(insertStatement, 1, (agentId as NSString).utf8String, -1, nil) sqlite3_bind_text(insertStatement, 1, (sessionId as NSString).utf8String, -1, nil) sqlite3_bind_text(insertStatement, 2, (message as NSString).utf8String, -1, nil) sqlite3_bind_text(insertStatement, 3, (sender as NSString).utf8String, -1, nil) @@ -107,17 +110,17 @@ public class ChatStorageHelper { * @param pageSize 每页消息数量 * @return 消息列表的JSON字符串 */ - public func getMessages(sessionId: String, page: Int, pageSize: Int) -> String { + public func getMessages(agentId: String, page: Int, pageSize: Int) -> String { let offset = (page - 1) * pageSize var messagesArray: [[String: Any]] = [] // 首先获取总记录数 - let countQuery = "SELECT COUNT(*) FROM messages WHERE session_id = ?" + let countQuery = "SELECT COUNT(*) FROM messages WHERE agent_id = ?" var countStatement: OpaquePointer? var totalCount = 0 if sqlite3_prepare_v2(db, countQuery, -1, &countStatement, nil) == SQLITE_OK { - sqlite3_bind_text(countStatement, 1, (sessionId as NSString).utf8String, -1, nil) + sqlite3_bind_text(countStatement, 1, (agentId as NSString).utf8String, -1, nil) if sqlite3_step(countStatement) == SQLITE_ROW { totalCount = Int(sqlite3_column_int(countStatement, 0)) } @@ -130,7 +133,7 @@ public class ChatStorageHelper { let queryString = """ SELECT id, message, sender, metadata, created_at FROM messages - WHERE session_id = ? + WHERE agent_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ? """ @@ -138,7 +141,7 @@ public class ChatStorageHelper { var queryStatement: OpaquePointer? if sqlite3_prepare_v2(db, queryString, -1, &queryStatement, nil) == SQLITE_OK { - sqlite3_bind_text(queryStatement, 1, (sessionId as NSString).utf8String, -1, nil) + sqlite3_bind_text(queryStatement, 1, (agentId as NSString).utf8String, -1, nil) sqlite3_bind_int(queryStatement, 2, Int32(pageSize)) sqlite3_bind_int(queryStatement, 3, Int32(offset)) @@ -163,7 +166,7 @@ public class ChatStorageHelper { var messageDict: [String: Any] = [ "id": id, - "sessionId": sessionId, // 添加sessionId字段 + "agentId": agentId, // 添加agentId字段 "message": message, "sender": sender, "timestamp": timestamp // 使用timestamp而不是created_at @@ -227,13 +230,13 @@ public class ChatStorageHelper { * @param messageIds 可选的消息ID列表,指定则删除特定消息 * @return 是否删除成功 */ - public func deleteMessages(sessionId: String?, messageIds: [Int]?) -> Bool { - if let sessionId = sessionId { - let deleteString = "DELETE FROM messages WHERE session_id = ?;" + public func deleteMessages(agentId: String?, messageIds: [Int]?) -> Bool { + if let agentId = agentId { + let deleteString = "DELETE FROM messages WHERE agent_id = ?;" var deleteStatement: OpaquePointer? if sqlite3_prepare_v2(db, deleteString, -1, &deleteStatement, nil) == SQLITE_OK { - sqlite3_bind_text(deleteStatement, 1, (sessionId as NSString).utf8String, -1, nil) + sqlite3_bind_text(deleteStatement, 1, (agentId as NSString).utf8String, -1, nil) if sqlite3_step(deleteStatement) == SQLITE_DONE { sqlite3_finalize(deleteStatement) @@ -302,7 +305,7 @@ public class ChatStorageHelper { * @param limit 限制数量 * @return 消息列表,按时间正序排列 */ - public func getRecentMessages(sessionId: String, limit: Int) -> [[String: Any]] { + public func getRecentMessages(agentId: String, limit: Int) -> [[String: Any]] { var messages: [[String: Any]] = [] // 首先检查数据库中是否有该会话的消息 @@ -310,7 +313,7 @@ public class ChatStorageHelper { var countStatement: OpaquePointer? if sqlite3_prepare_v2(db, countQuery, -1, &countStatement, nil) == SQLITE_OK { - sqlite3_bind_text(countStatement, 1, (sessionId as NSString).utf8String, -1, nil) + sqlite3_bind_text(countStatement, 1, (agentId as NSString).utf8String, -1, nil) if sqlite3_step(countStatement) == SQLITE_ROW { _ = Int(sqlite3_column_int(countStatement, 0)) } @@ -331,7 +334,7 @@ public class ChatStorageHelper { var queryStatement: OpaquePointer? if sqlite3_prepare_v2(db, queryString, -1, &queryStatement, nil) == SQLITE_OK { - sqlite3_bind_text(queryStatement, 1, (sessionId as NSString).utf8String, -1, nil) + sqlite3_bind_text(queryStatement, 1, (agentId as NSString).utf8String, -1, nil) sqlite3_bind_int(queryStatement, 2, Int32(limit)) while sqlite3_step(queryStatement) == SQLITE_ROW { diff --git a/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/SwiftChatStoragePlugin.swift b/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/SwiftChatStoragePlugin.swift index c281ce955..23db5fa13 100644 --- a/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/SwiftChatStoragePlugin.swift +++ b/local_plugins/chat_storage/ios/chat_storage/Sources/chat_storage/SwiftChatStoragePlugin.swift @@ -20,6 +20,7 @@ public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { case "saveMessage": do { guard let args = call.arguments as? [String: Any], + let agentId = args["agentId"] as? String, let sessionId = args["sessionId"] as? String, let message = args["message"] as? String, let sender = args["sender"] as? String else { @@ -30,6 +31,7 @@ public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { let metadata = args["metadata"] as? String let id = ChatStorageHelper.shared.saveMessage( + agentId:agentId, sessionId: sessionId, message: message, sender: sender, @@ -49,7 +51,7 @@ public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { case "getMessages": do { guard let args = call.arguments as? [String: Any], - let sessionId = args["sessionId"] as? String else { + let agentId = args["agentId"] as? String else { result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) return } @@ -58,7 +60,7 @@ public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { let pageSize = args["pageSize"] as? Int ?? 20 let messagesJson = ChatStorageHelper.shared.getMessages( - sessionId: sessionId, + agentId: agentId, page: page, pageSize: pageSize ) @@ -77,11 +79,11 @@ public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { return } - let sessionId = args["sessionId"] as? String + let agentId = args["agentId"] as? String let messageIds = args["messageIds"] as? [Int] let success = ChatStorageHelper.shared.deleteMessages( - sessionId: sessionId, + agentId: agentId, messageIds: messageIds ) @@ -109,4 +111,4 @@ public class SwiftChatStoragePlugin: NSObject, FlutterPlugin { result(FlutterMethodNotImplemented) } } -} \ No newline at end of file +} diff --git a/local_plugins/chat_storage/lib/chat_storage.dart b/local_plugins/chat_storage/lib/chat_storage.dart index 7e474e97a..f5e9bf9d1 100644 --- a/local_plugins/chat_storage/lib/chat_storage.dart +++ b/local_plugins/chat_storage/lib/chat_storage.dart @@ -6,7 +6,8 @@ import 'package:flutter/services.dart'; /// 聊天记录数据模型 class ChatMessage { final int? id; - final String sessionId; + final String agentId; // 智能体ID + final String sessionId; // 会话ID final String message; final String sender; final String timestamp; @@ -14,6 +15,7 @@ class ChatMessage { ChatMessage({ this.id, + required this.agentId, required this.sessionId, required this.message, required this.sender, @@ -24,6 +26,7 @@ class ChatMessage { Map toMap() { return { 'id': id, + 'agentid': agentId, 'sessionId': sessionId, 'message': message, 'sender': sender, @@ -35,6 +38,7 @@ class ChatMessage { factory ChatMessage.fromMap(Map map) { return ChatMessage( id: map['id'], + agentId: map['agentId'], sessionId: map['sessionId'], message: map['message'], sender: map['sender'], @@ -44,8 +48,6 @@ class ChatMessage { } } - - /// 分页结果数据模型 class PagedResult { final List messages; @@ -69,6 +71,7 @@ class ChatStorage { /// 保存一条聊天记录 static Future> saveMessage({ + required String agentId, required String sessionId, required String message, required String sender, @@ -76,6 +79,7 @@ class ChatStorage { }) async { try { final result = await _channel.invokeMethod('saveMessage', { + 'agentId': agentId, 'sessionId': sessionId, 'message': message, 'sender': sender, @@ -89,13 +93,13 @@ class ChatStorage { /// 获取指定会话的聊天记录(支持分页) static Future getMessages({ - required String sessionId, + required String agentId, int page = 1, int pageSize = 20, }) async { try { final String result = await _channel.invokeMethod('getMessages', { - 'sessionId': sessionId, + 'agentId': agentId, 'page': page, 'pageSize': pageSize, }); @@ -125,20 +129,18 @@ class ChatStorage { } } - - /// 删除聊天记录,可按会话ID或消息ID列表删除 static Future> deleteMessages({ - String? sessionId, + String? agentId, List? messageIds, }) async { try { - if (sessionId == null && (messageIds == null || messageIds.isEmpty)) { + if (agentId == null && (messageIds == null || messageIds.isEmpty)) { return {'success': false, 'error': '必须提供sessionId或messageIds'}; } final result = await _channel.invokeMethod('deleteMessages', { - 'sessionId': sessionId, + 'agentId': agentId, 'messageIds': messageIds, }); return Map.from(result); @@ -156,6 +158,4 @@ class ChatStorage { return {'success': false, 'error': e.toString()}; } } - - }