From 915c692000d3f3be15cf497cae3b691c8d0633b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 27 Aug 2025 20:21:38 +0800 Subject: [PATCH 01/25] [feat] optimized model scope download manager --- .../ModelList/Network/AsyncSemaphore.swift | 36 + .../Network/DownloadConfigurationModels.swift | 58 ++ .../Network/DynamicConcurrencyManager.swift | 267 ++++++++ .../ModelList/Network/ModelClient.swift | 61 +- .../ModelDownloadManagerProtocol.swift | 161 +++++ .../Network/ModelScopeDownloadManager.swift | 44 +- .../OptimizedModelScopeDownloadManager.swift | 638 ++++++++++++++++++ 7 files changed, 1224 insertions(+), 41 deletions(-) create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/AsyncSemaphore.swift create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/AsyncSemaphore.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/AsyncSemaphore.swift new file mode 100644 index 00000000..9ecf3831 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/AsyncSemaphore.swift @@ -0,0 +1,36 @@ +// +// AsyncSemaphore.swift +// MNNLLMiOS +// +// Created by 游薪渝(揽清) on 2025/8/27. +// + +import Foundation + +public actor AsyncSemaphore { + private var value: Int + private var waiters: [CheckedContinuation] = [] + + init(value: Int) { + self.value = value + } + + func wait() async { + if value > 0 { + value -= 1 + } else { + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + } + + func signal() { + if waiters.isEmpty { + value += 1 + } else { + let waiter = waiters.removeFirst() + waiter.resume() + } + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift new file mode 100644 index 00000000..07affcde --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift @@ -0,0 +1,58 @@ +// +// DownloadConfigurationModels.swift +// MNNLLMiOS +// +// Created by 游薪渝(揽清) on 2025/8/27. +// + +import Foundation + +public struct DownloadConfig { + let maxConcurrentDownloads: Int + let chunkSize: Int64 + let largeFileThreshold: Int64 + let maxRetries: Int + let retryDelay: TimeInterval + + public static let `default` = DownloadConfig( + maxConcurrentDownloads: 3, + chunkSize: 20 * 1024 * 1024, // 20MB chunks + largeFileThreshold: 100 * 1024 * 1024, // 100MB threshold for chunking + maxRetries: 500, + retryDelay: 1.0 + ) +} + +// MARK: - Download Task + +struct DownloadTask { + let file: ModelFile + let destinationPath: String + let priority: TaskPriority + var chunks: [ChunkInfo] = [] + var isChunked: Bool { !chunks.isEmpty } +} + +struct ChunkInfo { + let index: Int + let startOffset: Int64 + let endOffset: Int64 + let tempURL: URL + var isCompleted: Bool = false + var downloadedBytes: Int64 = 0 +} + +// MARK: - Progress Tracking + +struct DownloadProgress { + var totalBytes: Int64 = 0 + var downloadedBytes: Int64 = 0 + var activeDownloads: Int = 0 + var completedFiles: Int = 0 + var totalFiles: Int = 0 + + var progress: Double { + guard totalBytes > 0 else { return 0.0 } + return Double(downloadedBytes) / Double(totalBytes) + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift new file mode 100644 index 00000000..3d23d414 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift @@ -0,0 +1,267 @@ +// +// DynamicConcurrencyManager.swift +// MNNLLMiOS +// +// Created by 游薪渝(揽清) on 2025/1/15. +// + +import Foundation +import Network + +// MARK: - Dynamic Concurrency Configuration + +/** + Dynamic concurrency control manager - intelligently adjusts concurrency parameters + based on chunk count and network conditions + + Usage Examples: + + ```swift + // 1. Create dynamic concurrency manager + let concurrencyManager = DynamicConcurrencyManager() + + // 2. Get download strategy for file + let fileSize: Int64 = 240 * 1024 * 1024 // 240MB文件 + let strategy = await concurrencyManager.recommendDownloadStrategy(fileSize: fileSize) + + print(strategy.description) + // Output might be: + // Download Strategy: + // - Use Chunking: Yes + // - Chunk Size: 10MB + // - Chunk Count: 24 + // - Concurrency: 6 (24 chunks / 4 ideal chunks per concurrency = 6) + // - Network Type: wifi + // - Device Performance: high + + // 3. Create DownloadConfig using strategy + let dynamicConfig = DownloadConfig( + maxConcurrentDownloads: strategy.concurrency, + chunkSize: strategy.chunkSize, + largeFileThreshold: strategy.chunkSize * 2, + maxRetries: 3, + retryDelay: 2.0 + ) + + Best Practices: + + 1. **Intelligent Concurrency Control**: + - For 24 chunks: use 6-8 concurrent downloads (instead of fixed 3) + - For 4 chunks: use 2-3 concurrent downloads + - For 1 chunk: use 1 concurrent download + + 2. **Network Adaptation**: + - WiFi: larger chunk size and more concurrency + - 4G: medium chunk size and concurrency + - 3G: small chunk size and less concurrency + + 3. **Device Performance Consideration**: + - High-performance devices: can handle more concurrency + - Low-performance devices: reduce concurrency to avoid lag + + 4. **Dynamic Adjustment**: + - Automatically adjust strategy when network status changes + - Dynamically optimize based on actual download performance + */ + +public struct DynamicConcurrencyConfig { + + let baseConcurrency: Int + + let maxConcurrency: Int + + let minConcurrency: Int + + let idealChunksPerConcurrency: Int + + let networkTypeMultiplier: Double + + let devicePerformanceMultiplier: Double + + let largeFileThreshold: Int64 + + public static let `default` = DynamicConcurrencyConfig( + baseConcurrency: 3, + maxConcurrency: 8, + minConcurrency: 1, + idealChunksPerConcurrency: 3, + networkTypeMultiplier: 1.0, + devicePerformanceMultiplier: 1.0, + largeFileThreshold: 50 * 1024 * 1024 + ) +} + +// MARK: - Network Type Detection + +public enum NetworkType { + case wifi + case cellular + case lowBandwidth + case unknown + + var concurrencyMultiplier: Double { + switch self { + case .wifi: return 1.5 + case .cellular: return 1.0 + case .lowBandwidth: return 0.5 + case .unknown: return 0.8 + } + } + + var recommendedChunkSize: Int64 { + switch self { + case .wifi: return 20 * 1024 * 1024 // 20MB + case .cellular: return 10 * 1024 * 1024 // 10MB + case .lowBandwidth: return 5 * 1024 * 1024 // 5MB + case .unknown: return 8 * 1024 * 1024 // 5MB + } + } +} + +// MARK: - Device Performance Detection + +public enum DevicePerformance { + case high + case medium + case low + + var concurrencyMultiplier: Double { + switch self { + case .high: return 1.3 + case .medium: return 1.0 + case .low: return 0.7 + } + } + + static func detect() -> DevicePerformance { + let processInfo = ProcessInfo.processInfo + let physicalMemory = processInfo.physicalMemory + let processorCount = processInfo.processorCount + + // Determine device performance based on memory and processor count + if physicalMemory >= 6 * 1024 * 1024 * 1024 && processorCount >= 6 { // 6GB+ RAM, 6+ cores + return .high + } else if physicalMemory >= 3 * 1024 * 1024 * 1024 && processorCount >= 4 { // 3GB+ RAM, 4+ cores + return .medium + } else { + return .low + } + } +} + +// MARK: - Dynamic Concurrency Manager + +@available(iOS 13.4, macOS 10.15, *) +public actor DynamicConcurrencyManager { + private let config: DynamicConcurrencyConfig + private let networkMonitor: NWPathMonitor + private var currentNetworkType: NetworkType = .unknown + private let devicePerformance: DevicePerformance + + public init(config: DynamicConcurrencyConfig = .default) { + self.config = config + self.networkMonitor = NWPathMonitor() + self.devicePerformance = DevicePerformance.detect() + + Task { + await startNetworkMonitoring() + } + } + + private func startNetworkMonitoring() { + networkMonitor.pathUpdateHandler = { [weak self] path in + Task { + await self?.updateNetworkType(from: path) + } + } + + let queue = DispatchQueue(label: "NetworkMonitor") + networkMonitor.start(queue: queue) + } + + private func updateNetworkType(from path: NWPath) { + if path.usesInterfaceType(.wifi) { + currentNetworkType = .wifi + } else if path.usesInterfaceType(.cellular) { + currentNetworkType = .cellular + } else if path.status == .satisfied { + currentNetworkType = .unknown + } else { + currentNetworkType = .lowBandwidth + } + } + + /// Calculate optimal concurrency based on chunk count and current network conditions + public func calculateOptimalConcurrency(chunkCount: Int) -> Int { + // Base calculation: based on chunk count and ideal ratio + let baseConcurrency = max(1, min(chunkCount / config.idealChunksPerConcurrency, config.baseConcurrency)) + + // Apply network type weight + let networkAdjusted = Double(baseConcurrency) * currentNetworkType.concurrencyMultiplier + + // Apply device performance weight + let performanceAdjusted = networkAdjusted * devicePerformance.concurrencyMultiplier + + // Ensure within reasonable range + let finalConcurrency = Int(performanceAdjusted.rounded()) + + return max(config.minConcurrency, min(config.maxConcurrency, finalConcurrency)) + } + + /// Get current network status information + public func getNetworkInfo() -> (type: NetworkType, performance: DevicePerformance) { + return (currentNetworkType, devicePerformance) + } + + /// Get recommended chunk size + public func recommendedChunkSize() -> Int64 { + let baseChunkSize = currentNetworkType.recommendedChunkSize + let performanceMultiplier = devicePerformance.concurrencyMultiplier + + return Int64(Double(baseChunkSize) * performanceMultiplier) + } + + /// Recommend download strategy based on file size and network conditions + public func recommendDownloadStrategy(fileSize: Int64) -> DownloadStrategy { + let chunkSize = recommendedChunkSize() + let shouldUseChunking = fileSize > config.largeFileThreshold + let chunkCount = shouldUseChunking ? Int(ceil(Double(fileSize) / Double(chunkSize))) : 1 + let optimalConcurrency = calculateOptimalConcurrency(chunkCount: chunkCount) + + return DownloadStrategy( + shouldUseChunking: shouldUseChunking, + chunkSize: chunkSize, + chunkCount: chunkCount, + concurrency: optimalConcurrency, + networkType: currentNetworkType, + devicePerformance: devicePerformance + ) + } + + deinit { + networkMonitor.cancel() + } +} + +// MARK: - Download Strategy + +public struct DownloadStrategy { + let shouldUseChunking: Bool + let chunkSize: Int64 + let chunkCount: Int + let concurrency: Int + let networkType: NetworkType + let devicePerformance: DevicePerformance + + var description: String { + return """ + Download Strategy: + - Use Chunking: \(shouldUseChunking ? "Yes" : "No") + - Chunk Size: \(chunkSize / 1024 / 1024)MB + - Chunk Count: \(chunkCount) + - Concurrency: \(concurrency) + - Network Type: \(networkType) + - Device Performance: \(devicePerformance) + """ + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift index f9d073c2..e9fa768b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift @@ -9,6 +9,8 @@ import Hub import Foundation class ModelClient { + // MARK: - Properties + private let maxRetries = 5 private let baseMirrorURL = "https://hf-mirror.com" @@ -18,7 +20,8 @@ class ModelClient { // Debug flag to use local mock data instead of network API private let useLocalMockData = false - private var currentDownloadManager: ModelScopeDownloadManager? + private var currentDownloadManager: ModelDownloadManagerProtocol? + private let downloadManagerFactory: ModelDownloadManagerFactory private lazy var baseURLString: String = { switch ModelSourceManager.shared.selectedSource { @@ -29,7 +32,13 @@ class ModelClient { } }() - init() {} + /** Creates a ModelClient with dependency injection for download manager + * - Parameter downloadManagerFactory: Factory for creating download managers. + * Defaults to DefaultModelDownloadManagerFactory + */ + init(downloadManagerFactory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory()) { + self.downloadManagerFactory = downloadManagerFactory + } func getModelInfo() async throws -> TBDataResponse { if useLocalMockData { @@ -92,8 +101,10 @@ class ModelClient { func downloadModel(model: ModelInfo, progress: @escaping (Double) -> Void) async throws { switch ModelSourceManager.shared.selectedSource { - case .modelScope, .modeler: - try await downloadFromModelScope(model, progress: progress) + case .modelScope: + try await downloadFromModelScope(model, source: .modelScope , progress: progress) + case .modeler: + try await downloadFromModelScope(model, source: .modeler, progress: progress) case .huggingFace: try await downloadFromHuggingFace(model, progress: progress) } @@ -103,36 +114,46 @@ class ModelClient { * Cancels the current download operation */ func cancelDownload() async { - if let manager = currentDownloadManager { - await manager.cancelDownload() - currentDownloadManager = nil - print("Download cancelled") + switch ModelSourceManager.shared.selectedSource { + case .modelScope, .modeler: + await currentDownloadManager?.cancelDownload() + case .huggingFace: + // TODO: await currentDownloadManager?.cancelDownload() +// try await mirrorHubApi. + print("cant stop") } } + /** - * Downloads model from ModelScope platform + * Downloads model from ModelScope/Modler platform * * @param model The ModelInfo object to download * @param progress Progress callback for download updates * @throws Download or network related errors */ private func downloadFromModelScope(_ model: ModelInfo, + source: ModelSource, progress: @escaping (Double) -> Void) async throws { - let ModelScopeId = model.id - let config = URLSessionConfiguration.default - config.timeoutIntervalForRequest = 30 - config.timeoutIntervalForResource = 300 - let manager = ModelScopeDownloadManager.init(repoPath: ModelScopeId, config: config, enableLogging: true, source: ModelSourceManager.shared.selectedSource) - currentDownloadManager = manager + currentDownloadManager = downloadManagerFactory.createDownloadManager( + repoPath: model.id, + source: .modelScope + ) - try await manager.downloadModel(to:"huggingface/models/taobao-mnn", modelId: ModelScopeId, modelName: model.modelName) { fileProgress in - Task { @MainActor in - progress(fileProgress) + do { + try await currentDownloadManager?.downloadModel( + to: "huggingface/models/taobao-mnn", + modelId: model.id, + modelName: model.modelName + ) { fileProgress in + Task { @MainActor in + progress(fileProgress) + } } + } catch { + print("Download failed: \(error)") + throw NetworkError.downloadFailed } - - currentDownloadManager = nil } /** diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift new file mode 100644 index 00000000..0ad1b2e1 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift @@ -0,0 +1,161 @@ +// +// ModelDownloadManagerProtocol.swift +// MNNLLMiOS +// +// Created by Dependency Injection Refactor +// + +import Foundation + +/// Protocol defining the interface for model download managers +/// Enables dependency injection and provides a common contract for different download implementations +@available(iOS 13.4, macOS 10.15, *) +public protocol ModelDownloadManagerProtocol: Actor, Sendable { + + /// Downloads a model from the repository + /// - Parameters: + /// - destinationFolder: Base folder where the model will be downloaded + /// - modelId: Unique identifier for the model + /// - modelName: Display name of the model (used for folder creation) + /// - progress: Optional closure called with download progress (0.0 to 1.0) + /// - Throws: ModelScopeError for network, file system, or validation failures + func downloadModel( + to destinationFolder: String, + modelId: String, + modelName: String, + progress: ((Double) -> Void)? + ) async throws + + /// Cancels the current download operation + /// Should preserve temporary files to support resume functionality + func cancelDownload() async +} + +/// Type-erased wrapper for ModelDownloadManagerProtocol +/// Provides concrete type that can be stored as a property while maintaining protocol flexibility +@available(iOS 13.4, macOS 10.15, *) +public actor AnyModelDownloadManager: ModelDownloadManagerProtocol { + + private let _downloadModel: (String, String, String, ((Double) -> Void)?) async throws -> Void + private let _cancelDownload: () async -> Void + + /// Creates a type-erased wrapper around any ModelDownloadManagerProtocol implementation + /// - Parameter manager: The concrete download manager to wrap + public init(_ manager: T) { + self._downloadModel = { destinationFolder, modelId, modelName, progress in + try await manager.downloadModel( + to: destinationFolder, + modelId: modelId, + modelName: modelName, + progress: progress + ) + } + self._cancelDownload = { + await manager.cancelDownload() + } + } + + public func downloadModel( + to destinationFolder: String, + modelId: String, + modelName: String, + progress: ((Double) -> Void)? = nil + ) async throws { + try await _downloadModel(destinationFolder, modelId, modelName, progress) + } + + public func cancelDownload() async { + await _cancelDownload() + } +} + +/// Factory protocol for creating download managers +/// Enables different creation strategies while maintaining type safety +@available(iOS 13.4, macOS 10.15, *) +public protocol ModelDownloadManagerFactory { + + /// Creates a download manager for the specified repository and source + /// - Parameters: + /// - repoPath: Repository path in format "owner/model-name" + /// - source: The model source (ModelScope or Modeler) + /// - Returns: A download manager conforming to ModelDownloadManagerProtocol + func createDownloadManager( + repoPath: String, + source: ModelSource + ) -> any ModelDownloadManagerProtocol +} + +/// Default factory implementation that creates OptimizedModelScopeDownloadManager instances +@available(iOS 13.4, macOS 10.15, *) +public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { + + private let config: DownloadConfig + private let sessionConfig: URLSessionConfiguration + private let enableLogging: Bool + private let concurrencyConfig: DynamicConcurrencyConfig + + /// Creates a factory with specified configuration + /// - Parameters: + /// - config: Download configuration settings + /// - sessionConfig: URLSession configuration + /// - enableLogging: Whether to enable debug logging + /// - concurrencyConfig: Dynamic concurrency configuration + public init( + config: DownloadConfig = .default, + sessionConfig: URLSessionConfiguration = .default, + enableLogging: Bool = true, + concurrencyConfig: DynamicConcurrencyConfig = .default + ) { + self.config = config + self.sessionConfig = sessionConfig + self.enableLogging = enableLogging + self.concurrencyConfig = concurrencyConfig + } + + public func createDownloadManager( + repoPath: String, + source: ModelSource + ) -> any ModelDownloadManagerProtocol { + return OptimizedModelScopeDownloadManager( + repoPath: repoPath, + config: config, + sessionConfig: sessionConfig, + enableLogging: enableLogging, + source: source, + concurrencyConfig: concurrencyConfig + ) + } +} + +/// Legacy factory implementation that creates ModelScopeDownloadManager instances +/// Provided for backward compatibility and testing purposes +@available(iOS 13.4, macOS 10.15, *) +public struct LegacyModelDownloadManagerFactory: ModelDownloadManagerFactory { + + private let sessionConfig: URLSessionConfiguration + private let enableLogging: Bool + + /// Creates a legacy factory with specified configuration + /// - Parameters: + /// - sessionConfig: URLSession configuration + /// - enableLogging: Whether to enable debug logging + public init( + sessionConfig: URLSessionConfiguration = .default, + enableLogging: Bool = true + ) { + self.sessionConfig = sessionConfig + self.enableLogging = enableLogging + } + + public func createDownloadManager( + repoPath: String, + source: ModelSource + ) -> any ModelDownloadManagerProtocol { + return ModelScopeDownloadManager( + repoPath: repoPath, + config: sessionConfig, + enableLogging: enableLogging, + source: source + ) + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift index 96d8141e..2a524122 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift @@ -16,7 +16,7 @@ import Foundation /// - File integrity validation /// - Directory structure preservation @available(iOS 13.4, macOS 10.15, *) -public actor ModelScopeDownloadManager: Sendable { +public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Properties private let repoPath: String @@ -275,7 +275,7 @@ public actor ModelScopeDownloadManager: Sendable { downloadedBytes += 1 bytesCount += 1 - // 减少进度回调频率:每 64KB * 5 更新一次而不是每1KB + // Reduce progress callback frequency: update every 64KB * 5 instead of every 1KB if bytesCount >= 64 * 1024 * 5 { onProgress(downloadedBytes) bytesCount = 0 @@ -331,24 +331,8 @@ public actor ModelScopeDownloadManager: Sendable { throw ModelScopeError.downloadCancelled } - func calculateTotalSize(files: [ModelFile]) async throws -> Int64 { - var size: Int64 = 0 - for file in files { - if file.type == "tree" { - let subFiles = try await fetchFileList( - root: file.path, - revision: revision - ) - size += try await calculateTotalSize(files: subFiles) - } else if file.type == "blob" { - size += Int64(file.size) - } - } - return size - } - if totalSize == 0 { - totalSize = try await calculateTotalSize(files: files) + totalSize = try await calculateTotalSize(files: files, revision: revision) print("Total download size: \(totalSize) bytes") } @@ -380,7 +364,9 @@ public actor ModelScopeDownloadManager: Sendable { progress: progress ) } else if file.type == "blob" { + ModelScopeLogger.debug("Downloading: \(file.name)") + if !storage.isFileDownloaded(file, at: destinationPath) { try await downloadFile( file: file, @@ -389,8 +375,8 @@ public actor ModelScopeDownloadManager: Sendable { let currentProgress = Double(self.downloadedSize + downloadedBytes) / Double(self.totalSize) self.updateProgress(currentProgress, callback: progress) }, - maxRetries: 500, - retryDelay: 1.0 + maxRetries: 500, // Can be made configurable + retryDelay: 1.0 // Can be made configurable ) downloadedSize += Int64(file.size) @@ -412,6 +398,22 @@ public actor ModelScopeDownloadManager: Sendable { } } + private func calculateTotalSize(files: [ModelFile], revision: String) async throws -> Int64 { + var size: Int64 = 0 + for file in files { + if file.type == "tree" { + let subFiles = try await fetchFileList( + root: file.path, + revision: revision + ) + size += try await calculateTotalSize(files: subFiles, revision: revision) + } else if file.type == "blob" { + size += Int64(file.size) + } + } + return size + } + private func resetDownloadState() async { totalFiles = 0 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift new file mode 100644 index 00000000..397f20ec --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift @@ -0,0 +1,638 @@ +// +// OptimizedModelScopeDownloadManager.swift +// MNNLLMiOS +// +// Created by 游薪渝(揽清) on 2025/8/21. +// + +import Foundation + +@available(iOS 13.4, macOS 10.15, *) +public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { + + // MARK: - Properties + + private let repoPath: String + private var session: URLSession + private let sessionConfig: URLSessionConfiguration + private var isSessionInvalidated = false + private let fileManager: FileManager + private let storage: ModelDownloadStorage + private let config: DownloadConfig + private let source: ModelSource + + private let concurrencyManager: DynamicConcurrencyManager + private var downloadSemaphore: AsyncSemaphore + private var downloadChunkSemaphore: AsyncSemaphore? + private var activeTasks: [String: Task] = [:] + private var downloadQueue: [DownloadTask] = [] + + private var progress = DownloadProgress() + private var progressCallback: ((Double) -> Void)? + + private var isCancelled: Bool = false + + // MARK: - Initialization + + public init( + repoPath: String, + config: DownloadConfig = .default, + sessionConfig: URLSessionConfiguration = .default, + enableLogging: Bool = true, + source: ModelSource, + concurrencyConfig: DynamicConcurrencyConfig = .default + ) { + self.repoPath = repoPath + self.config = config + self.source = source + self.sessionConfig = sessionConfig + self.fileManager = .default + self.storage = ModelDownloadStorage() + self.session = URLSession(configuration: sessionConfig) + self.concurrencyManager = DynamicConcurrencyManager(config: concurrencyConfig) + self.downloadSemaphore = AsyncSemaphore(value: config.maxConcurrentDownloads) + + ModelScopeLogger.isEnabled = enableLogging + } + + // MARK: - Public Methods + + public func downloadModel( + to destinationFolder: String = "", + modelId: String, + modelName: String, + progress: ((Double) -> Void)? = nil + ) async throws { + self.progressCallback = progress + + // Ensure we have a valid session and reset cancelled state + await ensureValidSession() + + ModelScopeLogger.info("Starting optimized download for modelId: \(modelId)") + + let destination = try resolveDestinationPath(base: destinationFolder, modelId: modelName) + ModelScopeLogger.info("Will download to: \(destination)") + + let files = try await fetchFileList(root: "", revision: "") + + // Calculate total size and prepare download tasks + try await prepareDownloadTasks(files: files, destinationPath: destination) + + // Start concurrent downloads + try await executeDownloads() + + // Check if download was cancelled during execution + if isCancelled { + ModelScopeLogger.info("Download was cancelled, maintaining current progress state") + throw ModelScopeError.downloadCancelled + } + + await updateProgress(1.0) + ModelScopeLogger.info("Download completed successfully") + } + + public func cancelDownload() async { + isCancelled = true + + // Cancel all active tasks + for (_, task) in activeTasks { + task.cancel() + } + activeTasks.removeAll() + + // Cancel all session tasks but don't invalidate the session + session.getAllTasks { tasks in + for task in tasks { + task.cancel() + } + } + + ModelScopeLogger.info("Download cancelled, temporary files preserved for resume") + } + + // MARK: - Private Methods - Task Preparation + + private func prepareDownloadTasks( + files: [ModelFile], + destinationPath: String + ) async throws { + downloadQueue.removeAll() + progress = DownloadProgress() + + try await processFiles(files, destinationPath: destinationPath) + + progress.totalFiles = downloadQueue.count + ModelScopeLogger.info("Prepared \(downloadQueue.count) download tasks, total size: \(progress.totalBytes) bytes") + } + + private func processFiles( + _ files: [ModelFile], + destinationPath: String + ) async throws { + for file in files { + if file.type == "tree" { + let newPath = (destinationPath as NSString) + .appendingPathComponent(file.name.sanitizedPath) + try fileManager.createDirectoryIfNeeded(at: newPath) + + let subFiles = try await fetchFileList(root: file.path, revision: "") + try await processFiles(subFiles, destinationPath: newPath) + } else if file.type == "blob" { + if !storage.isFileDownloaded(file, at: destinationPath) { + var task = DownloadTask( + file: file, + destinationPath: destinationPath, + priority: .medium + ) + + // Check if file should be chunked + if Int64(file.size) > config.largeFileThreshold { + task.chunks = await createChunks(for: file) + ModelScopeLogger.info("File \(file.name) will be downloaded in \(task.chunks.count) chunks") + } + + downloadQueue.append(task) + progress.totalBytes += Int64(file.size) + } else { + progress.downloadedBytes += Int64(file.size) + } + } + } + } + + private func createChunks(for file: ModelFile) async -> [ChunkInfo] { + let fileSize = Int64(file.size) + + let recommendedChunkSize = await concurrencyManager.recommendedChunkSize() + let chunkSize = min(recommendedChunkSize, config.chunkSize) + + let chunkCount = Int(ceil(Double(fileSize) / Double(chunkSize))) + var chunks: [ChunkInfo] = [] + + ModelScopeLogger.info("File \(file.name): using chunk size \(chunkSize / 1024 / 1024)MB, total \(chunkCount) chunks") + + for i in 0..= expectedChunkSize + } catch { + ModelScopeLogger.error("Failed to get chunk attributes: \(error)") + } + } + + chunks.append(ChunkInfo( + index: i, + startOffset: startOffset, + endOffset: endOffset, + tempURL: tempURL, + isCompleted: isCompleted, + downloadedBytes: downloadedBytes + )) + } + + return chunks + } + + // MARK: - Download Execution + + private func executeDownloads() async throws { + await withTaskGroup(of: Void.self) { group in + for task in downloadQueue { + if isCancelled { break } + + group.addTask { + await self.downloadSemaphore.wait() + defer { Task { await self.downloadSemaphore.signal() } } + + do { + if task.isChunked { + try await self.downloadFileInChunks(task: task) + } else { + try await self.downloadFileDirect(task: task) + } + + // Only mark as completed if not cancelled + if await !self.isCancelled { + await self.markFileCompleted(task: task) + } + } catch { + if await !self.isCancelled { + ModelScopeLogger.error("Failed to download \(task.file.name): \(error)") + } + } + } + } + } + } + + private func downloadFileInChunks(task: DownloadTask) async throws { + ModelScopeLogger.info("Starting chunked download for: \(task.file.name)") + + let concurrencyCount = await concurrencyManager.calculateOptimalConcurrency(chunkCount: task.chunks.count) + + downloadChunkSemaphore = AsyncSemaphore(value: concurrencyCount) + + ModelScopeLogger.info("Using optimal concurrency: \(concurrencyCount) for \(task.chunks.count) chunks") + + // Check if any chunks are already completed and update progress + let completedBytes = task.chunks.reduce(0) { total, chunk in + return total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes) + } + if completedBytes > 0 { + await updateDownloadProgress(bytes: completedBytes) + ModelScopeLogger.info("Found \(completedBytes) bytes already downloaded for \(task.file.name)") + } + + try await withThrowingTaskGroup(of: Void.self) { group in + for chunk in task.chunks { + if isCancelled { break } + await self.downloadChunkSemaphore?.wait() + defer { Task { await self.downloadChunkSemaphore?.signal() } } + + if !chunk.isCompleted { + group.addTask { + try await self.downloadChunk(chunk: chunk, file: task.file) + } + } + } + + try await group.waitForAll() + } + + if isCancelled { + throw ModelScopeError.downloadCancelled + } + + // Merge chunks + try await mergeChunks(task: task) + } + + private func downloadChunk(chunk: ChunkInfo, file: ModelFile) async throws { + + if chunk.isCompleted { + ModelScopeLogger.info("Chunk \(chunk.index) already completed, skipping") + return + } + + var lastError: Error? + + // Retry logic with exponential backoff + for attempt in 0.. 0 { + request.setValue("bytes=\(resumeOffset)-\(remainingEndOffset)", forHTTPHeaderField: "Range") + ModelScopeLogger.info("Resuming chunk \(chunk.index) from offset \(chunk.downloadedBytes)") + } else { + request.setValue("bytes=\(chunk.startOffset)-\(chunk.endOffset)", forHTTPHeaderField: "Range") + } + + let (asyncBytes, response) = try await session.bytes(for: request) + try validateResponse(response) + + // Create or open file handle for writing + if chunk.downloadedBytes == 0 && !fileManager.fileExists(atPath: chunk.tempURL.path) { + fileManager.createFile(atPath: chunk.tempURL.path, contents: nil, attributes: nil) + } + + let fileHandle = try FileHandle(forWritingTo: chunk.tempURL) + defer { try? fileHandle.close() } + + if chunk.downloadedBytes > 0 { + try fileHandle.seekToEnd() + } + + var bytesCount = 0 + + for try await byte in asyncBytes { + if isCancelled { throw ModelScopeError.downloadCancelled } + + try fileHandle.write(contentsOf: [byte]) + bytesCount += 1 + + if bytesCount >= 64 * 1024 * 5 { + await updateDownloadProgress(bytes: Int64(bytesCount)) + bytesCount = 0 + } + } + + if bytesCount > 0 { + await updateDownloadProgress(bytes: Int64(bytesCount)) + } + + ModelScopeLogger.info("Chunk \(chunk.index) downloaded successfully") + + return // Success, exit retry loop + + } catch { + lastError = error + ModelScopeLogger.error("Chunk \(chunk.index) download attempt \(attempt + 1) failed: \(error)") + + // Don't retry if cancelled + if isCancelled { + throw ModelScopeError.downloadCancelled + } + + // Don't wait after the last attempt + if attempt < config.maxRetries - 1 { + let delay = config.retryDelay * pow(2.0, Double(attempt)) // Exponential backoff + ModelScopeLogger.info("Retrying chunk \(chunk.index) in \(delay) seconds...") + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + } + + // All retries failed + throw lastError ?? ModelScopeError.downloadFailed(NSError( + domain: "ModelScope", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "All chunk download attempts failed"] + )) + } + + private func downloadFileDirect(task: DownloadTask) async throws { + ModelScopeLogger.info("downloadFileDirect \(task.file.name)") + + let file = task.file + let destination = URL(fileURLWithPath: task.destinationPath) + .appendingPathComponent(file.name.sanitizedPath) + + let modelHash = repoPath.hash + let fileHash = file.path.hash + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp") + + var lastError: Error? + + // Retry logic with exponential backoff + for attempt in 0.. 0 { + request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range") + } + + let (asyncBytes, response) = try await session.bytes(for: request) + try validateResponse(response) + + let fileHandle = try FileHandle(forWritingTo: tempURL) + defer { try? fileHandle.close() } + + if resumeOffset > 0 { + try fileHandle.seek(toOffset: UInt64(resumeOffset)) + } + + var downloadedBytes: Int64 = resumeOffset + var bytesCount = 0 + + for try await byte in asyncBytes { + if isCancelled { throw ModelScopeError.downloadCancelled } + + try fileHandle.write(contentsOf: [byte]) + downloadedBytes += 1 + bytesCount += 1 + + // Update progress less frequently + if bytesCount >= 64 * 1024 * 5 { + await updateDownloadProgress(bytes: Int64(bytesCount)) + bytesCount = 0 + } + } + + // Final progress update + if bytesCount > 0 { + await updateDownloadProgress(bytes: Int64(bytesCount)) + } + + // Move to final destination + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + try fileManager.moveItem(at: tempURL, to: destination) + + ModelScopeLogger.info("File \(file.name) downloaded successfully") + return // Success, exit retry loop + + } catch { + lastError = error + ModelScopeLogger.error("File \(file.name) download attempt \(attempt + 1) failed: \(error)") + + // Don't retry if cancelled + if isCancelled { + throw ModelScopeError.downloadCancelled + } + + // Don't wait after the last attempt + if attempt < config.maxRetries - 1 { + let delay = config.retryDelay * pow(2.0, Double(attempt)) // Exponential backoff + ModelScopeLogger.info("Retrying file \(file.name) in \(delay) seconds...") + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + } + + // All retries failed + throw lastError ?? ModelScopeError.downloadFailed(NSError( + domain: "ModelScope", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "All file download attempts failed"] + )) + } + + private func mergeChunks(task: DownloadTask) async throws { + let destination = URL(fileURLWithPath: task.destinationPath) + .appendingPathComponent(task.file.name.sanitizedPath) + + // Create destination file if it doesn't exist + if !fileManager.fileExists(atPath: destination.path) { + fileManager.createFile(atPath: destination.path, contents: nil, attributes: nil) + } + + let finalFileHandle = try FileHandle(forWritingTo: destination) + defer { try? finalFileHandle.close() } + + // Sort chunks by index and merge + let sortedChunks = task.chunks.sorted { $0.index < $1.index } + + for chunk in sortedChunks { + if isCancelled { throw ModelScopeError.downloadCancelled } + + let chunkData = try Data(contentsOf: chunk.tempURL) + try finalFileHandle.write(contentsOf: chunkData) + + // Clean up chunk file + try? fileManager.removeItem(at: chunk.tempURL) + } + + ModelScopeLogger.info("Successfully merged \(sortedChunks.count) chunks for \(task.file.name)") + } + + // MARK: - Helper Methods + + private func ensureValidSession() async { + if isSessionInvalidated { + // Create a new session if the previous one was invalidated + session = URLSession(configuration: sessionConfig) + isSessionInvalidated = false + ModelScopeLogger.info("Created new URLSession after previous invalidation") + } + + // Always reset the cancelled flag when ensuring valid session + isCancelled = false + } + + private func buildDownloadURL(for file: ModelFile) throws -> URL { + if source == .modelScope { + return try buildURL( + path: "/repo", + queryItems: [ + URLQueryItem(name: "Revision", value: "master"), + URLQueryItem(name: "FilePath", value: file.path) + ] + ) + } else { + return try buildModelerURL( + path: file.path, + queryItems: [] + ) + } + } + + private func markFileCompleted(task: DownloadTask) async { + progress.completedFiles += 1 + storage.saveFileStatus(task.file, at: task.destinationPath) + ModelScopeLogger.info("Completed: \(task.file.name) (\(progress.completedFiles)/\(progress.totalFiles))") + } + + private func updateDownloadProgress(bytes: Int64) async { + progress.downloadedBytes += bytes + await updateProgress(progress.progress) + } + + private func updateProgress(_ value: Double) async { + guard let callback = progressCallback else { return } + + Task { @MainActor in + callback(value) + } + } + + // MARK: - Network Methods + + private func fetchFileList( + root: String, + revision: String + ) async throws -> [ModelFile] { + let url = try buildURL( + path: "/repo/files", + queryItems: [ + URLQueryItem(name: "Root", value: root), + URLQueryItem(name: "Revision", value: revision) + ] + ) + let (data, response) = try await session.data(from: url) + try validateResponse(response) + + let modelResponse = try JSONDecoder().decode(ModelResponse.self, from: data) + return modelResponse.data.files + } + + private func buildURL( + path: String, + queryItems: [URLQueryItem] + ) throws -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = "modelscope.cn" + components.path = "/api/v1/models/\(repoPath)\(path)" + components.queryItems = queryItems + + guard let url = components.url else { + throw ModelScopeError.invalidURL + } + return url + } + + private func buildModelerURL( + path: String, + queryItems: [URLQueryItem] + ) throws -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = "modelers.cn" + components.path = "/coderepo/web/v1/file/\(repoPath)/main/media/\(path)" + components.queryItems = queryItems + + guard let url = components.url else { + throw ModelScopeError.invalidURL + } + return url + } + + private func validateResponse(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) else { + throw ModelScopeError.invalidResponse + } + } + + private func resolveDestinationPath( + base: String, + modelId: String + ) throws -> String { + guard let documentsPath = FileManager.default + .urls(for: .documentDirectory, in: .userDomainMask) + .first else { + throw ModelScopeError.fileSystemError( + NSError(domain: "ModelScope", code: -1, userInfo: [ + NSLocalizedDescriptionKey: "Cannot access Documents directory" + ]) + ) + } + + let components = base.components(separatedBy: "/") + + var currentURL = documentsPath + components.forEach { component in + currentURL = currentURL.appendingPathComponent(component, isDirectory: true) + } + let modelScopePath = currentURL.appendingPathComponent(modelId, isDirectory: true) + + try fileManager.createDirectoryIfNeeded(at: modelScopePath.path) + + return modelScopePath.path + } + +} From e2aa9f49823695a05c1204c68a48bab5d2b828c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Thu, 28 Aug 2025 17:02:21 +0800 Subject: [PATCH 02/25] [update] download config --- .../Network/DynamicConcurrencyManager.swift | 4 ++-- .../OptimizedModelScopeDownloadManager.swift | 13 +++---------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift index 3d23d414..8baec348 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift @@ -2,7 +2,7 @@ // DynamicConcurrencyManager.swift // MNNLLMiOS // -// Created by 游薪渝(揽清) on 2025/1/15. +// Created by 游薪渝(揽清) on 2025/8/27. // import Foundation @@ -21,7 +21,7 @@ import Network let concurrencyManager = DynamicConcurrencyManager() // 2. Get download strategy for file - let fileSize: Int64 = 240 * 1024 * 1024 // 240MB文件 + let fileSize: Int64 = 240 * 1024 * 1024 // 240MB let strategy = await concurrencyManager.recommendDownloadStrategy(fileSize: fileSize) print(strategy.description) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift index 397f20ec..fce731c9 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift @@ -2,7 +2,7 @@ // OptimizedModelScopeDownloadManager.swift // MNNLLMiOS // -// Created by 游薪渝(揽清) on 2025/8/21. +// Created by 游薪渝(揽清) on 2025/8/27. // import Foundation @@ -24,7 +24,6 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { private let concurrencyManager: DynamicConcurrencyManager private var downloadSemaphore: AsyncSemaphore private var downloadChunkSemaphore: AsyncSemaphore? - private var activeTasks: [String: Task] = [:] private var downloadQueue: [DownloadTask] = [] private var progress = DownloadProgress() @@ -94,12 +93,6 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { public func cancelDownload() async { isCancelled = true - // Cancel all active tasks - for (_, task) in activeTasks { - task.cancel() - } - activeTasks.removeAll() - // Cancel all session tasks but don't invalidate the session session.getAllTasks { tasks in for task in tasks { @@ -332,7 +325,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { try fileHandle.write(contentsOf: [byte]) bytesCount += 1 - if bytesCount >= 64 * 1024 * 5 { + if bytesCount >= 64 * 1024 { await updateDownloadProgress(bytes: Int64(bytesCount)) bytesCount = 0 } @@ -425,7 +418,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { bytesCount += 1 // Update progress less frequently - if bytesCount >= 64 * 1024 * 5 { + if bytesCount >= 64 * 1024 { await updateDownloadProgress(bytes: Int64(bytesCount)) bytesCount = 0 } From d62514f2a30041cf23978149453bf8401708ff54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 14:22:25 +0800 Subject: [PATCH 03/25] [update] config and remove unused code --- .../MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift | 4 ++-- .../MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift | 9 --------- .../MainTab/ModelList/Network/ModelClient.swift | 7 +++++-- ...tionModels.swift => ModelDownloadConfiguration.swift} | 2 +- .../ModelList/Network/ModelDownloadManagerProtocol.swift | 2 +- 5 files changed, 9 insertions(+), 15 deletions(-) rename apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/{DownloadConfigurationModels.swift => ModelDownloadConfiguration.swift} (97%) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift index a877a170..a07dae2c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift @@ -49,7 +49,7 @@ actor UIUpdateOptimizer { // Configuration constants private let batchSize: Int = 5 // Batch size threshold for immediate flush - private let flushInterval: TimeInterval = 0.03 // 30ms throttling interval + private let flushInterval: TimeInterval = 0.5 private init() {} @@ -133,4 +133,4 @@ actor UIUpdateOptimizer { flushUpdates(completion: completion) } } -} \ No newline at end of file +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift index dd9a3ab2..68b50ad0 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift @@ -37,15 +37,6 @@ struct LLMChatView: View { .setStreamingMessageProvider { viewModel.currentStreamingMessageId } -// messageBuilder: { message, positionInGroup, positionInCommentsGroup, showContextMenuClosure, messageActionClosure, showAttachmentClosure in -// LLMChatMessageView( -// message: message, -// positionInGroup: positionInGroup, -// showContextMenuClosure: showContextMenuClosure, -// messageActionClosure: messageActionClosure, -// showAttachmentClosure: showAttachmentClosure -// ) -// } .setAvailableInput( self.title.lowercased().contains("vl") ? .textAndMedia : self.title.lowercased().contains("audio") ? .textAndAudio : diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift index e9fa768b..d394df9d 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift @@ -151,8 +151,11 @@ class ModelClient { } } } catch { - print("Download failed: \(error)") - throw NetworkError.downloadFailed + if case ModelScopeError.downloadCancelled = error { + throw ModelScopeError.downloadCancelled + } else { + throw NetworkError.downloadFailed + } } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadConfiguration.swift similarity index 97% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadConfiguration.swift index 07affcde..2534c129 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DownloadConfigurationModels.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadConfiguration.swift @@ -1,5 +1,5 @@ // -// DownloadConfigurationModels.swift +// ModelDownloadConfiguration.swift // MNNLLMiOS // // Created by 游薪渝(揽清) on 2025/8/27. diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift index 0ad1b2e1..05000525 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift @@ -2,7 +2,7 @@ // ModelDownloadManagerProtocol.swift // MNNLLMiOS // -// Created by Dependency Injection Refactor +// Created by 游薪渝(揽清) on 2025/8/27. // import Foundation From 10a35ceafd061c0757242c6a54a1bd60c1eecf9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 14:48:01 +0800 Subject: [PATCH 04/25] [add] downloader docment --- .../Network/DynamicConcurrencyManager.swift | 2 +- .../ModelList/Network/ModelClient.swift | 57 +++- ...Logger.swift => ModelDownloadLogger.swift} | 5 +- ...nager.swift => ModelDownloadManager.swift} | 208 ++++++++++-- .../ModelDownloadManagerProtocol.swift | 158 ++++++--- .../Network/ModelDownloadStorage.swift | 86 +++++ .../Network/ModelScopeDownloadManager.swift | 304 ++++++++++++++---- 7 files changed, 693 insertions(+), 127 deletions(-) rename apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/{ModelScopeLogger.swift => ModelDownloadLogger.swift} (87%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/{OptimizedModelScopeDownloadManager.swift => ModelDownloadManager.swift} (70%) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift index 8baec348..b6413c21 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift @@ -87,7 +87,7 @@ public struct DynamicConcurrencyConfig { idealChunksPerConcurrency: 3, networkTypeMultiplier: 1.0, devicePerformanceMultiplier: 1.0, - largeFileThreshold: 50 * 1024 * 1024 + largeFileThreshold: 100 * 1024 * 1024 ) } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift index d394df9d..6e6e1c7a 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift @@ -8,6 +8,34 @@ import Hub import Foundation +/** + * ModelClient - Unified model download and management client + * + * This class provides a centralized interface for downloading models from multiple sources + * including ModelScope, Modeler, and HuggingFace platforms. It handles source-specific + * download logic, progress tracking, and error handling. + * + * Key Features: + * - Multi-platform support (ModelScope, Modeler, HuggingFace) + * - Progress tracking with throttling to prevent UI stuttering + * - Automatic fallback to local mock data for development + * - Dependency injection for download managers + * - Cancellation support for ongoing downloads + * + * Architecture: + * - Uses factory pattern for download manager creation + * - Implements strategy pattern for different download sources + * - Provides async/await interface for modern Swift concurrency + * + * Usage: + * ```swift + * let client = ModelClient() + * let models = try await client.getModelInfo() + * try await client.downloadModel(model: selectedModel) { progress in + * print("Download progress: \(progress * 100)%") + * } + * ``` + */ class ModelClient { // MARK: - Properties @@ -32,14 +60,25 @@ class ModelClient { } }() - /** Creates a ModelClient with dependency injection for download manager - * - Parameter downloadManagerFactory: Factory for creating download managers. - * Defaults to DefaultModelDownloadManagerFactory + /** + * Creates a ModelClient with dependency injection for download manager + * + * @param downloadManagerFactory Factory for creating download managers. + * Defaults to DefaultModelDownloadManagerFactory */ init(downloadManagerFactory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory()) { self.downloadManagerFactory = downloadManagerFactory } + /** + * Retrieves model information from the configured API endpoint + * + * This method fetches the latest model catalog from the network API. + * In debug mode or when network fails, it falls back to local mock data. + * + * @return TBDataResponse containing the model catalog + * @throws NetworkError if both network request and local fallback fail + */ func getModelInfo() async throws -> TBDataResponse { if useLocalMockData { // Debug mode: use local mock data @@ -212,6 +251,18 @@ class ModelClient { } +/** + * NetworkError - Enumeration of network-related errors + * + * This enum defines the various error conditions that can occur during + * network operations and model downloads. + * + * Error Cases: + * - invalidResponse: HTTP response is invalid or has non-200 status code + * - invalidData: Data received is corrupted or cannot be decoded + * - downloadFailed: Download operation failed due to network or file system issues + * - unknown: Unexpected error occurred during network operation + */ enum NetworkError: Error { case invalidResponse case invalidData diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeLogger.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadLogger.swift similarity index 87% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeLogger.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadLogger.swift index 5fc50b9a..34c9a5e1 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeLogger.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadLogger.swift @@ -8,14 +8,11 @@ import Foundation import os.log -public final class ModelScopeLogger { - // MARK: - Properties +public final class ModelDownloadLogger { static var isEnabled: Bool = false private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "ModelScope", category: "Download") - // MARK: - Logging Methods - static func debug(_ message: String) { guard isEnabled else { return } logger.debug("📥 \(message)") diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift similarity index 70% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift index fce731c9..b50ea793 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/OptimizedModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift @@ -1,5 +1,5 @@ // -// OptimizedModelScopeDownloadManager.swift +// ModelDownloadManager.swift // MNNLLMiOS // // Created by 游薪渝(揽清) on 2025/8/27. @@ -7,8 +7,51 @@ import Foundation +/** + * ModelDownloadManager - Advanced concurrent model download manager + * + * This actor-based download manager provides high-performance, resumable downloads + * with intelligent chunking, dynamic concurrency optimization, and comprehensive + * error handling for model files from various sources. + * + * Key Features: + * - Concurrent downloads with dynamic concurrency adjustment + * - Intelligent file chunking for large files (>50MB) + * - Resume capability with partial download preservation + * - Exponential backoff retry mechanism + * - Real-time progress tracking with throttling + * - Memory-efficient streaming downloads + * - Thread-safe operations using Swift Actor model + * + * Architecture: + * - Uses URLSession for network operations + * - Implements semaphore-based concurrency control + * - Supports both direct and chunked download strategies + * - Maintains download state persistence + * + * Performance Optimizations: + * - Dynamic chunk size calculation based on network conditions + * - Optimal concurrency level determination + * - Progress update throttling to prevent UI blocking + * - Temporary file management for resume functionality + * + * Usage: + * ```swift + * let manager = ModelDownloadManager( + * repoPath: "owner/model-name", + * source: .modelScope + * ) + * try await manager.downloadModel( + * to: "models", + * modelId: "example-model", + * modelName: "ExampleModel" + * ) { progress in + * print("Progress: \(progress * 100)%") + * } + * ``` + */ @available(iOS 13.4, macOS 10.15, *) -public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { +public actor ModelDownloadManager: ModelDownloadManagerProtocol { // MARK: - Properties @@ -33,6 +76,16 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Initialization + /** + * Initializes a new ModelDownloadManager with configurable parameters + * + * @param repoPath Repository path in format "owner/model-name" + * @param config Download configuration including retry and chunk settings + * @param sessionConfig URLSession configuration for network requests + * @param enableLogging Whether to enable detailed logging + * @param source Model source platform (ModelScope, Modeler, etc.) + * @param concurrencyConfig Dynamic concurrency management configuration + */ public init( repoPath: String, config: DownloadConfig = .default, @@ -51,11 +104,24 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { self.concurrencyManager = DynamicConcurrencyManager(config: concurrencyConfig) self.downloadSemaphore = AsyncSemaphore(value: config.maxConcurrentDownloads) - ModelScopeLogger.isEnabled = enableLogging + ModelDownloadLogger.isEnabled = enableLogging } // MARK: - Public Methods + /** + * Downloads a complete model with all its files + * + * This method orchestrates the entire download process including file discovery, + * task preparation, concurrent execution, and progress tracking. It supports + * resume functionality and handles various error conditions gracefully. + * + * @param destinationFolder Base folder for download (relative to Documents) + * @param modelId Unique identifier for the model + * @param modelName Display name used for folder creation + * @param progress Optional progress callback (0.0 to 1.0) + * @throws ModelScopeError for various download failures + */ public func downloadModel( to destinationFolder: String = "", modelId: String, @@ -67,10 +133,10 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Ensure we have a valid session and reset cancelled state await ensureValidSession() - ModelScopeLogger.info("Starting optimized download for modelId: \(modelId)") + ModelDownloadLogger.info("Starting optimized download for modelId: \(modelId)") let destination = try resolveDestinationPath(base: destinationFolder, modelId: modelName) - ModelScopeLogger.info("Will download to: \(destination)") + ModelDownloadLogger.info("Will download to: \(destination)") let files = try await fetchFileList(root: "", revision: "") @@ -82,14 +148,20 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Check if download was cancelled during execution if isCancelled { - ModelScopeLogger.info("Download was cancelled, maintaining current progress state") + ModelDownloadLogger.info("Download was cancelled, maintaining current progress state") throw ModelScopeError.downloadCancelled } await updateProgress(1.0) - ModelScopeLogger.info("Download completed successfully") + ModelDownloadLogger.info("Download completed successfully") } + /** + * Cancels all ongoing download operations while preserving partial downloads + * + * This method gracefully stops all active downloads and preserves temporary + * files to enable resume functionality in future download attempts. + */ public func cancelDownload() async { isCancelled = true @@ -100,11 +172,21 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { } } - ModelScopeLogger.info("Download cancelled, temporary files preserved for resume") + ModelDownloadLogger.info("Download cancelled, temporary files preserved for resume") } // MARK: - Private Methods - Task Preparation + /** + * Prepares download tasks by analyzing files and creating appropriate download strategies + * + * This method processes the file list and determines the optimal download approach + * for each file based on size, existing partial downloads, and configuration. + * + * @param files Array of ModelFile objects representing files to download + * @param destinationPath Target directory path for downloads + * @throws ModelScopeError for file system or processing errors + */ private func prepareDownloadTasks( files: [ModelFile], destinationPath: String @@ -115,9 +197,20 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { try await processFiles(files, destinationPath: destinationPath) progress.totalFiles = downloadQueue.count - ModelScopeLogger.info("Prepared \(downloadQueue.count) download tasks, total size: \(progress.totalBytes) bytes") + ModelDownloadLogger.info("Prepared \(downloadQueue.count) download tasks, total size: \(progress.totalBytes) bytes") } + /** + * Processes individual files and creates corresponding download tasks + * + * Analyzes each file to determine if it needs chunked or direct download + * based on file size and configuration thresholds. Handles directory creation + * and recursive file processing for nested structures. + * + * @param files Array of ModelFile objects to process + * @param destinationPath Base destination path for downloads + * @throws ModelScopeError for file system or network errors + */ private func processFiles( _ files: [ModelFile], destinationPath: String @@ -141,7 +234,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Check if file should be chunked if Int64(file.size) > config.largeFileThreshold { task.chunks = await createChunks(for: file) - ModelScopeLogger.info("File \(file.name) will be downloaded in \(task.chunks.count) chunks") + ModelDownloadLogger.info("File \(file.name) will be downloaded in \(task.chunks.count) chunks") } downloadQueue.append(task) @@ -153,6 +246,16 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { } } + /** + * Creates chunked download information for large files + * + * Divides large files into optimal chunks for concurrent downloading, + * calculates resume offsets for existing partial downloads, and creates + * chunk metadata with temporary file locations. + * + * @param file ModelFile object representing the file to chunk + * @return Array of ChunkInfo objects containing chunk metadata + */ private func createChunks(for file: ModelFile) async -> [ChunkInfo] { let fileSize = Int64(file.size) @@ -162,7 +265,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { let chunkCount = Int(ceil(Double(fileSize) / Double(chunkSize))) var chunks: [ChunkInfo] = [] - ModelScopeLogger.info("File \(file.name): using chunk size \(chunkSize / 1024 / 1024)MB, total \(chunkCount) chunks") + ModelDownloadLogger.info("File \(file.name): using chunk size \(chunkSize / 1024 / 1024)MB, total \(chunkCount) chunks") for i in 0..= expectedChunkSize } catch { - ModelScopeLogger.error("Failed to get chunk attributes: \(error)") + ModelDownloadLogger.error("Failed to get chunk attributes: \(error)") } } @@ -203,6 +306,15 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Download Execution + /** + * Executes download tasks with dynamic concurrency management + * + * Manages the concurrent execution of download tasks using semaphores + * and dynamic concurrency adjustment based on network performance. + * Handles both chunked and direct download strategies. + * + * @throws ModelScopeError if downloads fail or are cancelled + */ private func executeDownloads() async throws { await withTaskGroup(of: Void.self) { group in for task in downloadQueue { @@ -225,7 +337,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { } } catch { if await !self.isCancelled { - ModelScopeLogger.error("Failed to download \(task.file.name): \(error)") + ModelDownloadLogger.error("Failed to download \(task.file.name): \(error)") } } } @@ -233,14 +345,24 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { } } + /** + * Downloads a file using chunked strategy with resume capability + * + * Handles the download of individual file chunks with retry logic, + * progress tracking, and automatic chunk merging upon completion. + * Uses optimal concurrency for chunk downloads. + * + * @param task DownloadTask containing chunk information and file metadata + * @throws ModelScopeError for network or file system errors + */ private func downloadFileInChunks(task: DownloadTask) async throws { - ModelScopeLogger.info("Starting chunked download for: \(task.file.name)") + ModelDownloadLogger.info("Starting chunked download for: \(task.file.name)") let concurrencyCount = await concurrencyManager.calculateOptimalConcurrency(chunkCount: task.chunks.count) downloadChunkSemaphore = AsyncSemaphore(value: concurrencyCount) - ModelScopeLogger.info("Using optimal concurrency: \(concurrencyCount) for \(task.chunks.count) chunks") + ModelDownloadLogger.info("Using optimal concurrency: \(concurrencyCount) for \(task.chunks.count) chunks") // Check if any chunks are already completed and update progress let completedBytes = task.chunks.reduce(0) { total, chunk in @@ -248,7 +370,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { } if completedBytes > 0 { await updateDownloadProgress(bytes: completedBytes) - ModelScopeLogger.info("Found \(completedBytes) bytes already downloaded for \(task.file.name)") + ModelDownloadLogger.info("Found \(completedBytes) bytes already downloaded for \(task.file.name)") } try await withThrowingTaskGroup(of: Void.self) { group in @@ -275,10 +397,21 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { try await mergeChunks(task: task) } + /** + * Downloads a specific chunk with range requests and resume support + * + * Performs HTTP range request to download a specific portion of a file, + * with automatic resume from existing partial downloads and exponential + * backoff retry logic. + * + * @param chunk ChunkInfo containing chunk metadata and temporary file location + * @param file ModelFile object representing the source file + * @throws ModelScopeError for network or file system errors + */ private func downloadChunk(chunk: ChunkInfo, file: ModelFile) async throws { if chunk.isCompleted { - ModelScopeLogger.info("Chunk \(chunk.index) already completed, skipping") + ModelDownloadLogger.info("Chunk \(chunk.index) already completed, skipping") return } @@ -297,7 +430,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Set Range header for resumable download if chunk.downloadedBytes > 0 { request.setValue("bytes=\(resumeOffset)-\(remainingEndOffset)", forHTTPHeaderField: "Range") - ModelScopeLogger.info("Resuming chunk \(chunk.index) from offset \(chunk.downloadedBytes)") + ModelDownloadLogger.info("Resuming chunk \(chunk.index) from offset \(chunk.downloadedBytes)") } else { request.setValue("bytes=\(chunk.startOffset)-\(chunk.endOffset)", forHTTPHeaderField: "Range") } @@ -335,13 +468,13 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { await updateDownloadProgress(bytes: Int64(bytesCount)) } - ModelScopeLogger.info("Chunk \(chunk.index) downloaded successfully") + ModelDownloadLogger.info("Chunk \(chunk.index) downloaded successfully") return // Success, exit retry loop } catch { lastError = error - ModelScopeLogger.error("Chunk \(chunk.index) download attempt \(attempt + 1) failed: \(error)") + ModelDownloadLogger.error("Chunk \(chunk.index) download attempt \(attempt + 1) failed: \(error)") // Don't retry if cancelled if isCancelled { @@ -351,7 +484,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Don't wait after the last attempt if attempt < config.maxRetries - 1 { let delay = config.retryDelay * pow(2.0, Double(attempt)) // Exponential backoff - ModelScopeLogger.info("Retrying chunk \(chunk.index) in \(delay) seconds...") + ModelDownloadLogger.info("Retrying chunk \(chunk.index) in \(delay) seconds...") try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } @@ -365,8 +498,18 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { )) } + /** + * Downloads a file directly without chunking + * + * Performs a complete file download in a single request with resume + * capability and progress tracking for smaller files. Uses exponential + * backoff retry mechanism for failed attempts. + * + * @param task DownloadTask containing file information and destination + * @throws ModelScopeError for network or file system errors + */ private func downloadFileDirect(task: DownloadTask) async throws { - ModelScopeLogger.info("downloadFileDirect \(task.file.name)") + ModelDownloadLogger.info("downloadFileDirect \(task.file.name)") let file = task.file let destination = URL(fileURLWithPath: task.destinationPath) @@ -435,12 +578,12 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { } try fileManager.moveItem(at: tempURL, to: destination) - ModelScopeLogger.info("File \(file.name) downloaded successfully") + ModelDownloadLogger.info("File \(file.name) downloaded successfully") return // Success, exit retry loop } catch { lastError = error - ModelScopeLogger.error("File \(file.name) download attempt \(attempt + 1) failed: \(error)") + ModelDownloadLogger.error("File \(file.name) download attempt \(attempt + 1) failed: \(error)") // Don't retry if cancelled if isCancelled { @@ -450,7 +593,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Don't wait after the last attempt if attempt < config.maxRetries - 1 { let delay = config.retryDelay * pow(2.0, Double(attempt)) // Exponential backoff - ModelScopeLogger.info("Retrying file \(file.name) in \(delay) seconds...") + ModelDownloadLogger.info("Retrying file \(file.name) in \(delay) seconds...") try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } } @@ -464,6 +607,15 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { )) } + /** + * Merges downloaded chunks into the final file + * + * Combines all downloaded chunks in the correct order to create the + * final file, then cleans up temporary chunk files. + * + * @param task DownloadTask containing chunk information and destination + * @throws ModelScopeError for file system errors during merging + */ private func mergeChunks(task: DownloadTask) async throws { let destination = URL(fileURLWithPath: task.destinationPath) .appendingPathComponent(task.file.name.sanitizedPath) @@ -489,7 +641,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { try? fileManager.removeItem(at: chunk.tempURL) } - ModelScopeLogger.info("Successfully merged \(sortedChunks.count) chunks for \(task.file.name)") + ModelDownloadLogger.info("Successfully merged \(sortedChunks.count) chunks for \(task.file.name)") } // MARK: - Helper Methods @@ -499,7 +651,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { // Create a new session if the previous one was invalidated session = URLSession(configuration: sessionConfig) isSessionInvalidated = false - ModelScopeLogger.info("Created new URLSession after previous invalidation") + ModelDownloadLogger.info("Created new URLSession after previous invalidation") } // Always reset the cancelled flag when ensuring valid session @@ -526,7 +678,7 @@ public actor OptimizedModelScopeDownloadManager: ModelDownloadManagerProtocol { private func markFileCompleted(task: DownloadTask) async { progress.completedFiles += 1 storage.saveFileStatus(task.file, at: task.destinationPath) - ModelScopeLogger.info("Completed: \(task.file.name) (\(progress.completedFiles)/\(progress.totalFiles))") + ModelDownloadLogger.info("Completed: \(task.file.name) (\(progress.completedFiles)/\(progress.totalFiles))") } private func updateDownloadProgress(bytes: Int64) async { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift index 05000525..11073426 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift @@ -7,18 +7,45 @@ import Foundation -/// Protocol defining the interface for model download managers -/// Enables dependency injection and provides a common contract for different download implementations +/** + * Protocol defining the interface for model download managers + * + * This protocol enables dependency injection and provides a common contract for different download implementations. + * It supports concurrent downloads, progress tracking, and cancellation functionality. + * + * Key Features: + * - Asynchronous download operations with progress callbacks + * - Cancellation support with resume capability + * - Actor-based thread safety + * - Flexible destination path configuration + * + * Usage: + * ```swift + * let manager: ModelDownloadManagerProtocol = ModelDownloadManager(...) + * try await manager.downloadModel( + * to: "models", + * modelId: "example-model", + * modelName: "ExampleModel" + * ) { progress in + * print("Progress: \(progress * 100)%") + * } + * ``` + */ @available(iOS 13.4, macOS 10.15, *) public protocol ModelDownloadManagerProtocol: Actor, Sendable { - /// Downloads a model from the repository - /// - Parameters: - /// - destinationFolder: Base folder where the model will be downloaded - /// - modelId: Unique identifier for the model - /// - modelName: Display name of the model (used for folder creation) - /// - progress: Optional closure called with download progress (0.0 to 1.0) - /// - Throws: ModelScopeError for network, file system, or validation failures + /** + * Downloads a model from the repository with progress tracking + * + * This method initiates an asynchronous download operation for the specified model. + * It supports resume functionality and provides real-time progress updates. + * + * @param destinationFolder Base folder where the model will be downloaded + * @param modelId Unique identifier for the model in the repository + * @param modelName Display name of the model (used for folder creation) + * @param progress Optional closure called with download progress (0.0 to 1.0) + * @throws ModelScopeError for network, file system, or validation failures + */ func downloadModel( to destinationFolder: String, modelId: String, @@ -26,21 +53,45 @@ public protocol ModelDownloadManagerProtocol: Actor, Sendable { progress: ((Double) -> Void)? ) async throws - /// Cancels the current download operation - /// Should preserve temporary files to support resume functionality + /** + * Cancels the current download operation + * + * This method gracefully stops the download process while preserving temporary files + * to support resume functionality in future download attempts. + */ func cancelDownload() async } -/// Type-erased wrapper for ModelDownloadManagerProtocol -/// Provides concrete type that can be stored as a property while maintaining protocol flexibility +/** + * Type-erased wrapper for ModelDownloadManagerProtocol + * + * This class provides a concrete type that can be stored as a property while maintaining protocol flexibility. + * It wraps any ModelDownloadManagerProtocol implementation and forwards method calls to the underlying manager. + * + * Key Benefits: + * - Enables storing protocol instances as properties + * - Maintains type safety while providing flexibility + * - Supports dependency injection patterns + * - Preserves all protocol functionality + * + * Usage: + * ```swift + * let concreteManager = ModelDownloadManager(...) + * let anyManager = AnyModelDownloadManager(concreteManager) + * // Can now store anyManager as a property + * ``` + */ @available(iOS 13.4, macOS 10.15, *) public actor AnyModelDownloadManager: ModelDownloadManagerProtocol { private let _downloadModel: (String, String, String, ((Double) -> Void)?) async throws -> Void private let _cancelDownload: () async -> Void - /// Creates a type-erased wrapper around any ModelDownloadManagerProtocol implementation - /// - Parameter manager: The concrete download manager to wrap + /** + * Creates a type-erased wrapper around any ModelDownloadManagerProtocol implementation + * + * @param manager The concrete download manager to wrap + */ public init(_ manager: T) { self._downloadModel = { destinationFolder, modelId, modelName, progress in try await manager.downloadModel( @@ -69,23 +120,50 @@ public actor AnyModelDownloadManager: ModelDownloadManagerProtocol { } } -/// Factory protocol for creating download managers -/// Enables different creation strategies while maintaining type safety +/** + * Factory protocol for creating download managers + * + * This protocol enables different creation strategies while maintaining type safety. + * It provides a standardized way to create download managers with various configurations. + * + * Key Features: + * - Supports multiple download manager implementations + * - Enables dependency injection and testing + * - Provides consistent creation interface + * - Supports different model sources + * + * Usage: + * ```swift + * let factory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory() + * let manager = factory.createDownloadManager( + * repoPath: "owner/model-name", + * source: .modelScope + * ) + * ``` + */ @available(iOS 13.4, macOS 10.15, *) public protocol ModelDownloadManagerFactory { - /// Creates a download manager for the specified repository and source - /// - Parameters: - /// - repoPath: Repository path in format "owner/model-name" - /// - source: The model source (ModelScope or Modeler) - /// - Returns: A download manager conforming to ModelDownloadManagerProtocol + /** + * Creates a download manager for the specified repository and source + * + * @param repoPath Repository path in format "owner/model-name" + * @param source The model source (ModelScope or Modeler) + * @return A download manager conforming to ModelDownloadManagerProtocol + */ func createDownloadManager( repoPath: String, source: ModelSource ) -> any ModelDownloadManagerProtocol } -/// Default factory implementation that creates OptimizedModelScopeDownloadManager instances +/** + * Default factory implementation that creates OptimizedModelScopeDownloadManager instances + * + * This factory provides the standard implementation for creating download managers with + * advanced features including dynamic concurrency control, optimized performance settings, + * and comprehensive configuration options. + */ @available(iOS 13.4, macOS 10.15, *) public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { @@ -94,12 +172,14 @@ public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { private let enableLogging: Bool private let concurrencyConfig: DynamicConcurrencyConfig - /// Creates a factory with specified configuration - /// - Parameters: - /// - config: Download configuration settings - /// - sessionConfig: URLSession configuration - /// - enableLogging: Whether to enable debug logging - /// - concurrencyConfig: Dynamic concurrency configuration + /** + * Creates a factory with specified configuration + * + * @param config Download configuration settings + * @param sessionConfig URLSession configuration + * @param enableLogging Whether to enable debug logging + * @param concurrencyConfig Dynamic concurrency configuration + */ public init( config: DownloadConfig = .default, sessionConfig: URLSessionConfiguration = .default, @@ -116,7 +196,7 @@ public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { repoPath: String, source: ModelSource ) -> any ModelDownloadManagerProtocol { - return OptimizedModelScopeDownloadManager( + return ModelDownloadManager( repoPath: repoPath, config: config, sessionConfig: sessionConfig, @@ -127,18 +207,24 @@ public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { } } -/// Legacy factory implementation that creates ModelScopeDownloadManager instances -/// Provided for backward compatibility and testing purposes +/** + * Legacy factory implementation that creates ModelScopeDownloadManager instances + * + * This factory is provided for backward compatibility and testing purposes. + * It creates the original ModelScopeDownloadManager without advanced optimizations. + */ @available(iOS 13.4, macOS 10.15, *) public struct LegacyModelDownloadManagerFactory: ModelDownloadManagerFactory { private let sessionConfig: URLSessionConfiguration private let enableLogging: Bool - /// Creates a legacy factory with specified configuration - /// - Parameters: - /// - sessionConfig: URLSession configuration - /// - enableLogging: Whether to enable debug logging + /** + * Creates a legacy factory with specified configuration + * + * @param sessionConfig URLSession configuration + * @param enableLogging Whether to enable debug logging + */ public init( sessionConfig: URLSessionConfiguration = .default, enableLogging: Bool = true diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift index 4f1f6f3d..da17159b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift @@ -7,6 +7,44 @@ import Foundation +/** + * ModelDownloadStorage - Persistent storage manager for download state tracking + * + * This class provides comprehensive storage management for tracking downloaded model files, + * their metadata, and download completion status. It uses UserDefaults for persistence + * and maintains file integrity through size and revision validation. + * + * Key Features: + * - Persistent download state tracking across app sessions + * - File integrity validation using size and revision checks + * - Efficient storage using relative path mapping + * - Thread-safe operations with immediate persistence + * - Automatic cleanup and state management + * + * Architecture: + * - Uses UserDefaults as the underlying storage mechanism + * - Maintains a dictionary mapping relative paths to FileStatus objects + * - Provides atomic operations for state updates + * - Supports both individual file and batch operations + * + * Storage Format: + * - Files are indexed by relative paths from Documents directory + * - Each entry contains size, revision, and timestamp information + * - JSON encoding ensures cross-session compatibility + * + * Usage: + * ```swift + * let storage = ModelDownloadStorage() + * + * // Check if file is already downloaded + * if storage.isFileDownloaded(file, at: destinationPath) { + * print("File already exists and is up to date") + * } + * + * // Save download completion status + * storage.saveFileStatus(file, at: destinationPath) + * ``` + */ final class ModelDownloadStorage { // MARK: - Properties @@ -15,12 +53,27 @@ final class ModelDownloadStorage { // MARK: - Initialization + /** + * Initializes the storage manager with configurable UserDefaults + * + * @param userDefaults UserDefaults instance for persistence (defaults to .standard) + */ init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults } // MARK: - Public Methods + /** + * Checks if a file has been completely downloaded and is up to date + * + * Validates both file existence on disk and metadata consistency including + * file size and revision to ensure the downloaded file is current and complete. + * + * @param file ModelFile object containing expected metadata + * @param path Destination path where the file should be located + * @return true if file exists and matches expected metadata, false otherwise + */ func isFileDownloaded(_ file: ModelFile, at path: String) -> Bool { let filePath = (path as NSString).appendingPathComponent(file.name.sanitizedPath) let relativePath = getRelativePath(from: filePath) @@ -34,6 +87,16 @@ final class ModelDownloadStorage { return fileStatus.size == file.size && fileStatus.revision == file.revision } + /** + * Saves the download completion status for a file + * + * Records file metadata including size, revision, and download timestamp + * to persistent storage. This enables resume functionality and prevents + * unnecessary re-downloads of unchanged files. + * + * @param file ModelFile object containing file metadata + * @param path Destination path where the file was downloaded + */ func saveFileStatus(_ file: ModelFile, at path: String) { let filePath = (path as NSString).appendingPathComponent(file.name.sanitizedPath) let relativePath = getRelativePath(from: filePath) @@ -55,6 +118,12 @@ final class ModelDownloadStorage { } } + /** + * Retrieves all downloaded file statuses from persistent storage + * + * @return Dictionary mapping relative file paths to FileStatus objects, + * or nil if no download history exists + */ func getDownloadedFiles() -> [String: FileStatus]? { guard let data = userDefaults.data(forKey: downloadedFilesKey), let downloadedFiles = try? JSONDecoder().decode([String: FileStatus].self, from: data) else { @@ -63,6 +132,14 @@ final class ModelDownloadStorage { return downloadedFiles } + /** + * Removes download status for a specific file + * + * Cleans up storage by removing the file's download record, typically + * used when files are deleted or need to be re-downloaded. + * + * @param path Full path to the file whose status should be cleared + */ func clearFileStatus(at path: String) { let relativePath = getRelativePath(from: path) var downloadedFiles = getDownloadedFiles() ?? [:] @@ -76,6 +153,15 @@ final class ModelDownloadStorage { // MARK: - Private Methods + /** + * Converts absolute file paths to relative paths from Documents directory + * + * This normalization ensures consistent path handling across different + * app installations and device configurations. + * + * @param fullPath Absolute file path to convert + * @return Relative path from Documents directory, or original path if conversion fails + */ private func getRelativePath(from fullPath: String) -> String { guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path else { return fullPath diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift index 2a524122..2a4939ca 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift @@ -9,12 +9,56 @@ import Foundation // MARK: - ModelScopeDownloadManager -/// A manager class that handles downloading models from ModelScope repository -/// Supports features like: -/// - Resume interrupted downloads -/// - Progress tracking -/// - File integrity validation -/// - Directory structure preservation +/** + * ModelScopeDownloadManager - Specialized download manager for ModelScope and Modeler platforms + * + * This actor-based download manager provides platform-specific optimizations for downloading + * models from ModelScope and Modeler repositories. It implements intelligent resume functionality, + * comprehensive error handling, and maintains directory structure integrity. + * + * Key Features: + * - Platform-specific URL handling for ModelScope and Modeler + * - Intelligent resume capability with temporary file preservation + * - Real-time progress tracking with optimized callback frequency + * - Recursive directory structure preservation + * - File integrity validation using size verification + * - Exponential backoff retry mechanism with configurable attempts + * - Memory-efficient streaming downloads + * - Thread-safe operations using Swift Actor model + * + * Architecture: + * - Uses URLSession.bytes for memory-efficient streaming + * - Implements temporary file management for resume functionality + * - Supports both ModelScope and Modeler API endpoints + * - Maintains download state persistence through ModelDownloadStorage + * + * Performance Optimizations: + * - Progress update throttling (every 320KB) to prevent UI blocking + * - Temporary file reuse for interrupted downloads + * - Efficient directory traversal with recursive file discovery + * - Minimal memory footprint through streaming downloads + * + * Error Handling: + * - Comprehensive retry logic with exponential backoff + * - Graceful cancellation with state preservation + * - File integrity validation and automatic cleanup + * - Network error recovery with configurable retry attempts + * + * Usage: + * ```swift + * let manager = ModelScopeDownloadManager( + * repoPath: "damo/Qwen-1.5B", + * source: .modelScope + * ) + * try await manager.downloadModel( + * to: "models", + * modelId: "qwen-1.5b", + * modelName: "Qwen-1.5B" + * ) { progress in + * print("Progress: \(progress * 100)%") + * } + * ``` + */ @available(iOS 13.4, macOS 10.15, *) public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Properties @@ -38,15 +82,16 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Initialization - /// Creates a new ModelScope download manager - /// - Parameters: - /// - repoPath: The repository path in format "owner/model-name" - /// - config: URLSession configuration for customizing network behavior. - /// Use `.default` for standard downloads, `.background` for background downloads. - /// Defaults to `.default` - /// - enableLogging: Whether to enable debug logging. Defaults to true - /// - source: use modelScope or modeler - /// - Note: When using background configuration, the app must handle URLSession background events + /** + * Creates a new ModelScope download manager with platform-specific configuration + * + * @param repoPath Repository path in format "owner/model-name" + * @param config URLSession configuration for network behavior customization + * Use .default for standard downloads, .background for background downloads + * @param enableLogging Whether to enable detailed debug logging + * @param source Target platform (ModelScope or Modeler) + * @note When using background configuration, the app must handle URLSession background events + */ public init( repoPath: String, config: URLSessionConfiguration = .default, @@ -58,30 +103,35 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { self.storage = ModelDownloadStorage() self.session = URLSession(configuration: config) self.source = source - ModelScopeLogger.isEnabled = enableLogging + ModelDownloadLogger.isEnabled = enableLogging } // MARK: - Public Methods - /// Downloads a model from ModelScope repository - /// - Parameters: - /// - destinationPath: Local path where the model will be saved - /// - revision: Model revision/version to download (defaults to latest) - /// - progress: Closure called with download progress (0.0 to 1.0) - /// - Throws: ModelScopeError for network, file system, or validation failures - /// - Returns: Void when download completes successfully - /// - /// Example usage: - /// ```swift - /// let manager = ModelScopeDownloadManager(repoPath: "damo/Qwen-1.5B") - /// try await manager.downloadModel( - /// to: "/path/to/models", - /// progress: { progress in - /// print("Download progress: \(progress * 100)%") - /// } - /// ) - /// Will download to /path/to/models/Qwen-1.5B - /// ``` + /** + * Downloads a complete model from ModelScope or Modeler repository + * + * This method orchestrates the entire download process including file discovery, + * directory structure creation, resume functionality, and progress tracking. + * It supports both ModelScope and Modeler platforms with platform-specific optimizations. + * + * @param destinationFolder Base folder for download (relative to Documents) + * @param modelId Unique identifier for the model + * @param modelName Display name used for folder creation + * @param progress Optional progress callback (0.0 to 1.0) + * @throws ModelScopeError for network, file system, or validation failures + * + * Example: + * ```swift + * try await manager.downloadModel( + * to: "models", + * modelId: "qwen-1.5b", + * modelName: "Qwen-1.5B" + * ) { progress in + * print("Progress: \(progress * 100)%") + * } + * ``` + */ public func downloadModel( to destinationFolder: String = "", modelId: String, @@ -91,14 +141,14 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { isCancelled = false - ModelScopeLogger.info("Starting download for modelId: \(modelId)") + ModelDownloadLogger.info("Starting download for modelId: \(modelId)") let destination = try resolveDestinationPath(base: destinationFolder, modelId: modelName) - ModelScopeLogger.info("Will download to: \(destination)") + ModelDownloadLogger.info("Will download to: \(destination)") let files = try await fetchFileList(root: "", revision: "") totalFiles = files.count - ModelScopeLogger.info("Fetched \(files.count) files") + ModelDownloadLogger.info("Fetched \(files.count) files") try await downloadFiles( files: files, @@ -108,8 +158,13 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { ) } - /// Cancel download - /// Preserve downloaded temporary files to support resume functionality + /** + * Cancels all ongoing download operations while preserving resume capability + * + * This method gracefully stops all active downloads, closes file handles, + * and preserves temporary files to enable resume functionality in future attempts. + * The URLSession is invalidated to ensure clean cancellation. + */ public func cancelDownload() async { isCancelled = true @@ -120,17 +175,35 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { session.invalidateAndCancel() - ModelScopeLogger.info("Download cancelled, temporary files preserved for resume") + ModelDownloadLogger.info("Download cancelled, temporary files preserved for resume") } // MARK: - Private Methods - Progress Management + /** + * Updates download progress with throttling to prevent excessive UI updates + * + * @param progress Current progress value (0.0 to 1.0) + * @param callback Progress callback function to invoke on main thread + */ private func updateProgress(_ progress: Double, callback: @escaping (Double) -> Void) { Task { @MainActor in callback(progress) } } + /** + * Fetches the complete file list from ModelScope or Modeler repository + * + * This method queries the repository API to discover all available files, + * supporting both ModelScope and Modeler platform endpoints with proper + * error handling and response validation. + * + * @param root Root directory path to fetch files from + * @param revision Model revision/version to fetch files for + * @return Array of ModelFile objects representing repository files + * @throws ModelScopeError if request fails or response is invalid + */ private func fetchFileList( root: String, revision: String @@ -150,6 +223,28 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } + /** + * Downloads a single file with intelligent resume and retry mechanisms + * + * This method handles individual file downloads with comprehensive error recovery, + * resume functionality through temporary files, and progress tracking. It supports + * both ModelScope and Modeler platforms with platform-specific URL construction. + * + * Features: + * - Automatic resume from temporary files using HTTP Range requests + * - Exponential backoff retry mechanism (configurable attempts) + * - Memory-efficient streaming using URLSession.bytes + * - File integrity validation using size verification + * - Progress update throttling to prevent UI blocking + * - Graceful cancellation with state preservation + * + * @param file ModelFile metadata including path, size, and download information + * @param destinationPath Target local path for the downloaded file + * @param onProgress Progress callback receiving downloaded bytes count + * @param maxRetries Maximum number of retry attempts (default: 3) + * @param retryDelay Delay between retry attempts in seconds (default: 2.0) + * @throws ModelScopeError if download fails after all retry attempts + */ private func downloadFile( file: ModelFile, destinationPath: String, @@ -200,8 +295,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { let session = self.session - ModelScopeLogger.info("Starting download for file: \(file.name)") - ModelScopeLogger.debug("Destination path: \(destinationPath)") + ModelDownloadLogger.info("Starting download for file: \(file.name)") + ModelDownloadLogger.debug("Destination path: \(destinationPath)") let destination = URL(fileURLWithPath: destinationPath) .appendingPathComponent(file.name.sanitizedPath) @@ -216,7 +311,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { if fileManager.fileExists(atPath: tempURL.path) { let attributes = try fileManager.attributesOfItem(atPath: tempURL.path) resumeOffset = attributes[.size] as? Int64 ?? 0 - ModelScopeLogger.info("Resuming download from offset: \(resumeOffset)") + ModelDownloadLogger.info("Resuming download from offset: \(resumeOffset)") } else { try Data().write(to: tempURL) } @@ -242,13 +337,13 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { request.setValue("bytes=\(resumeOffset)-", forHTTPHeaderField: "Range") } - ModelScopeLogger.debug("Requesting URL: \(url)") + ModelDownloadLogger.debug("Requesting URL: \(url)") return try await withCheckedThrowingContinuation { continuation in currentDownloadTask = Task { do { let (asyncBytes, response) = try await session.bytes(for: request) - ModelScopeLogger.debug("Response status code: \((response as? HTTPURLResponse)?.statusCode ?? -1)") + ModelDownloadLogger.debug("Response status code: \((response as? HTTPURLResponse)?.statusCode ?? -1)") try validateResponse(response) let fileHandle = try FileHandle(forWritingTo: tempURL) @@ -310,7 +405,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } if !isCancelled { - ModelScopeLogger.error("Download failed: \(error.localizedDescription)") + ModelDownloadLogger.error("Download failed: \(error.localizedDescription)") storage.clearFileStatus(at: destination.path) } continuation.resume(throwing: error) @@ -319,13 +414,26 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } + /** + * Downloads files recursively with directory structure preservation + * + * This method processes the complete file list, creating necessary directory + * structures and downloading files in the correct order. It calculates total + * download size, handles existing files, and maintains progress tracking. + * + * @param files Array of ModelFile objects representing all repository files + * @param revision Model revision/version for download URLs + * @param destinationPath Base directory path for downloads + * @param progress Progress callback function (0.0 to 1.0) + * @throws ModelScopeError if any file download fails + */ private func downloadFiles( files: [ModelFile], revision: String, destinationPath: String, progress: @escaping (Double) -> Void ) async throws { - ModelScopeLogger.info("Starting download with \(files.count) files") + ModelDownloadLogger.info("Starting download with \(files.count) files") if isCancelled { throw ModelScopeError.downloadCancelled @@ -342,12 +450,12 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { throw ModelScopeError.downloadCancelled } - ModelScopeLogger.debug("Processing: \(file.name), type: \(file.type)") + ModelDownloadLogger.debug("Processing: \(file.name), type: \(file.type)") if file.type == "tree" { let newPath = (destinationPath as NSString) .appendingPathComponent(file.name.sanitizedPath) - ModelScopeLogger.debug("Creating directory: \(newPath)") + ModelDownloadLogger.debug("Creating directory: \(newPath)") try fileManager.createDirectoryIfNeeded(at: newPath) @@ -355,7 +463,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { root: file.path, revision: revision ) - ModelScopeLogger.debug("Found \(subFiles.count) subfiles in \(file.path)") + ModelDownloadLogger.debug("Found \(subFiles.count) subfiles in \(file.path)") try await downloadFiles( files: subFiles, @@ -365,7 +473,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { ) } else if file.type == "blob" { - ModelScopeLogger.debug("Downloading: \(file.name)") + ModelDownloadLogger.debug("Downloading: \(file.name)") if !storage.isFileDownloaded(file, at: destinationPath) { try await downloadFile( @@ -381,11 +489,11 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { downloadedSize += Int64(file.size) storage.saveFileStatus(file, at: destinationPath) - ModelScopeLogger.info("Downloaded and saved: \(file.name)") + ModelDownloadLogger.info("Downloaded and saved: \(file.name)") } else { downloadedSize += Int64(file.size) - ModelScopeLogger.debug("File exists: \(file.name)") + ModelDownloadLogger.debug("File exists: \(file.name)") } let currentProgress = Double(downloadedSize) / Double(totalSize) @@ -398,6 +506,17 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } + /** + * Calculates the total download size for progress tracking + * + * Recursively traverses directory structures to compute the total size + * of all files that need to be downloaded, enabling accurate progress reporting. + * + * @param files Array of ModelFile objects to calculate size for + * @param revision Model revision for fetching subdirectory contents + * @return Total size in bytes across all files + * @throws ModelScopeError if file list fetching fails + */ private func calculateTotalSize(files: [ModelFile], revision: String) async throws -> Int64 { var size: Int64 = 0 for file in files { @@ -415,6 +534,12 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } + /** + * Resets internal download state for a fresh download session + * + * Clears progress counters and prepares the manager for a new download operation. + * This method is called at the beginning of each download to ensure clean state. + */ private func resetDownloadState() async { totalFiles = 0 downloadedFiles = 0 @@ -423,6 +548,12 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { lastUpdatedBytes = 0 } + /** + * Resets the cancellation flag to allow new download operations + * + * Clears all download state including cancellation status and progress counters, + * preparing the manager for a completely fresh download session. + */ private func resetCancelStatus() { isCancelled = false @@ -433,6 +564,12 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { lastUpdatedBytes = 0 } + /** + * Safely closes the current file handle to prevent resource leaks + * + * This method ensures proper cleanup of file handles during cancellation + * or error conditions, preventing file descriptor leaks. + */ private func closeFileHandle() async { do { try currentFileHandle?.close() @@ -442,6 +579,17 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } + /** + * Constructs ModelScope API URLs with proper query parameters + * + * Builds complete URLs for ModelScope repository API endpoints, + * handling URL encoding and validation. + * + * @param path API endpoint path to append to base URL + * @param queryItems URL query parameters for the request + * @return Constructed and validated URL + * @throws ModelScopeError.invalidURL if URL construction fails + */ private func buildURL( path: String, queryItems: [URLQueryItem] @@ -458,6 +606,17 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return url } + /** + * Constructs Modeler platform URLs with proper query parameters + * + * Builds complete URLs for Modeler repository API endpoints, + * handling URL encoding and validation for the Modeler platform. + * + * @param path File path within the repository + * @param queryItems URL query parameters for the request + * @return Constructed and validated URL + * @throws ModelScopeError.invalidURL if URL construction fails + */ private func buildModelerURL( path: String, queryItems: [URLQueryItem] @@ -474,6 +633,15 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return url } + /** + * Validates HTTP response status codes for successful requests + * + * Ensures the HTTP response indicates success (2xx status codes) + * and throws appropriate errors for failed requests. + * + * @param response URLResponse to validate + * @throws ModelScopeError.invalidResponse if status code indicates failure + */ private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { @@ -481,6 +649,17 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } + /** + * Resolves and creates the complete destination path for model downloads + * + * Constructs the full local file system path where the model will be downloaded, + * creating necessary directory structures and validating access permissions. + * + * @param base Base folder path relative to Documents directory + * @param modelId Model identifier used for folder naming + * @return Absolute path to the model download directory + * @throws ModelScopeError.fileSystemError if directory creation fails + */ private func resolveDestinationPath( base: String, modelId: String @@ -508,10 +687,25 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return modelScopePath.path } + /** + * Thread-safe setter for the current file handle + * + * @param handle FileHandle instance to set, or nil to clear + */ private func setCurrentFileHandle(_ handle: FileHandle?) { currentFileHandle = handle } + /** + * Retrieves the size of a temporary file for resume functionality + * + * Calculates the current size of a temporary download file to determine + * the resume offset for interrupted downloads. + * + * @param file ModelFile to get temporary file size for + * @param destinationPath Destination path used for temp file naming + * @return Size of temporary file in bytes, or 0 if file doesn't exist + */ private func getTempFileSize(for file: ModelFile, destinationPath: String) -> Int64 { let modelHash = repoPath.hash let fileHash = file.path.hash @@ -526,7 +720,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { let attributes = try fileManager.attributesOfItem(atPath: tempURL.path) return attributes[.size] as? Int64 ?? 0 } catch { - ModelScopeLogger.error("Failed to get temp file size for \(file.name): \(error)") + ModelDownloadLogger.error("Failed to get temp file size for \(file.name): \(error)") return 0 } } From 7d31c0b6c54dcbe8132a2440660abc5eef6eb34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 15:38:24 +0800 Subject: [PATCH 05/25] [update] docment with Apple style --- .../Chat/Services/PerformanceMonitor.swift | 118 +++--- .../Chat/Services/UIUpdateOptimizer.swift | 124 +++--- .../Chat/Views/LLMMessageTextView.swift | 176 ++++---- .../Helpers/BenchmarkResultsHelper.swift | 8 +- .../Benchmark/Models/BenchmarkErrorCode.swift | 6 +- .../Benchmark/Models/BenchmarkProgress.swift | 6 +- .../Benchmark/Models/BenchmarkResult.swift | 6 +- .../Benchmark/Models/BenchmarkResults.swift | 6 +- .../Models/BenchmarkStatistics.swift | 6 +- .../Benchmark/Models/DeviceInfoHelper.swift | 6 +- .../MainTab/Benchmark/Models/ModelItem.swift | 6 +- .../Benchmark/Models/ModelListManager.swift | 6 +- .../Benchmark/Models/ProgressType.swift | 6 +- .../Benchmark/Models/RuntimeParameters.swift | 6 +- .../Benchmark/Models/SpeedStatistics.swift | 6 +- .../Benchmark/Models/TestInstance.swift | 6 +- .../Benchmark/Models/TestParameters.swift | 6 +- .../Benchmark/Services/BenchmarkService.swift | 14 +- .../ViewModels/BenchmarkViewModel.swift | 8 +- .../Views/BenchmarkSubViews/MetricCard.swift | 8 +- .../ModelSelectionCard.swift | 6 +- .../PerformanceMetricView.swift | 6 +- .../BenchmarkSubViews/ProgressCard.swift | 6 +- .../Views/BenchmarkSubViews/ResultsCard.swift | 6 +- .../Views/BenchmarkSubViews/StatusCard.swift | 6 +- .../Benchmark/Views/BenchmarkView.swift | 6 +- .../Network/DynamicConcurrencyManager.swift | 148 ++++--- .../ModelList/Network/ModelClient.swift | 159 ++++--- .../Network/ModelDownloadManager.swift | 285 ++++++------- .../ModelDownloadManagerProtocol.swift | 224 +++++----- .../Network/ModelDownloadStorage.swift | 160 ++++--- .../Network/ModelScopeDownloadManager.swift | 392 ++++++++---------- .../ModelRowSubviews/ActionButtonsView.swift | 3 - .../MNNLLMiOS/Util/FileOperationManager.swift | 200 ++++----- 34 files changed, 996 insertions(+), 1145 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/PerformanceMonitor.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/PerformanceMonitor.swift index b0520fac..51bc30e5 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/PerformanceMonitor.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/PerformanceMonitor.swift @@ -8,49 +8,47 @@ import Foundation import UIKit -/** - * PerformanceMonitor - A singleton utility for monitoring and measuring UI performance - * - * This class provides real-time performance monitoring capabilities to help identify - * UI update bottlenecks, frame drops, and slow operations in iOS applications. - * It's particularly useful during development to ensure smooth user experience. - * - * Key Features: - * - Real-time FPS monitoring and frame drop detection - * - UI update lag detection with customizable thresholds - * - Execution time measurement for specific operations - * - Automatic performance statistics reporting - * - Thread-safe singleton implementation - * - * Usage Examples: - * - * 1. Monitor UI Updates: - * ```swift - * // Call this in your UI update methods - * PerformanceMonitor.shared.recordUIUpdate() - * ``` - * - * 2. Measure Operation Performance: - * ```swift - * let result = PerformanceMonitor.shared.measureExecutionTime(operation: "Data Processing") { - * // Your expensive operation here - * return processLargeDataSet() - * } - * ``` - * - * 3. Integration in ViewModels: - * ```swift - * func updateUI() { - * PerformanceMonitor.shared.recordUIUpdate() - * // Your UI update code - * } - * ``` - * - * Performance Thresholds: - * - Target FPS: 60 FPS - * - Frame threshold: 25ms (1.5x normal frame time) - * - Slow operation threshold: 16ms (1 frame time) - */ +/// PerformanceMonitor - A singleton utility for monitoring and measuring UI performance +/// +/// This class provides real-time performance monitoring capabilities to help identify +/// UI update bottlenecks, frame drops, and slow operations in iOS applications. +/// It's particularly useful during development to ensure smooth user experience. +/// +/// Key Features: +/// - Real-time FPS monitoring and frame drop detection +/// - UI update lag detection with customizable thresholds +/// - Execution time measurement for specific operations +/// - Automatic performance statistics reporting +/// - Thread-safe singleton implementation +/// +/// Usage Examples: +/// +/// 1. Monitor UI Updates: +/// ```swift +/// // Call this in your UI update methods +/// PerformanceMonitor.shared.recordUIUpdate() +/// ``` +/// +/// 2. Measure Operation Performance: +/// ```swift +/// let result = PerformanceMonitor.shared.measureExecutionTime(operation: "Data Processing") { +/// // Your expensive operation here +/// return processLargeDataSet() +/// } +/// ``` +/// +/// 3. Integration in ViewModels: +/// ```swift +/// func updateUI() { +/// PerformanceMonitor.shared.recordUIUpdate() +/// // Your UI update code +/// } +/// ``` +/// +/// Performance Thresholds: +/// - Target FPS: 60 FPS +/// - Frame threshold: 25ms (1.5x normal frame time) +/// - Slow operation threshold: 16ms (1 frame time) class PerformanceMonitor { static let shared = PerformanceMonitor() @@ -62,13 +60,11 @@ class PerformanceMonitor { private init() {} - /** - * Records a UI update event and monitors performance metrics - * - * Call this method whenever you perform UI updates to track performance. - * It automatically detects frame drops and calculates FPS statistics. - * Performance statistics are logged every second. - */ + /// Records a UI update event and monitors performance metrics + /// + /// Call this method whenever you perform UI updates to track performance. + /// It automatically detects frame drops and calculates FPS statistics. + /// Performance statistics are logged every second. func recordUIUpdate() { let currentTime = Date() let timeDiff = currentTime.timeIntervalSince(lastUpdateTime) @@ -95,18 +91,16 @@ class PerformanceMonitor { } } - /** - * Measures execution time for a specific operation - * - * Wraps any operation and measures its execution time. Operations taking - * longer than 16ms (1 frame time) are logged as slow operations. - * - * - Parameters: - * - operation: A descriptive name for the operation being measured - * - block: The operation to measure - * - Returns: The result of the operation - * - Throws: Re-throws any error thrown by the operation - */ + /// Measures execution time for a specific operation + /// + /// Wraps any operation and measures its execution time. Operations taking + /// longer than 16ms (1 frame time) are logged as slow operations. + /// + /// - Parameters: + /// - operation: A descriptive name for the operation being measured + /// - block: The operation to measure + /// - Returns: The result of the operation + /// - Throws: Re-throws any error thrown by the operation func measureExecutionTime(operation: String, block: () throws -> T) rethrows -> T { let startTime = CFAbsoluteTimeGetCurrent() let result = try block() diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift index a07dae2c..b3d0152f 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Services/UIUpdateOptimizer.swift @@ -8,38 +8,36 @@ import Foundation import SwiftUI -/** - * UIUpdateOptimizer - A utility for batching and throttling UI updates to improve performance - * - * This actor-based optimizer helps reduce the frequency of UI updates by batching multiple - * updates together and applying throttling mechanisms. It's particularly useful for scenarios - * like streaming text updates, real-time data feeds, or any situation where frequent UI - * updates might cause performance issues. - * - * Key Features: - * - Batches multiple updates into a single operation - * - Applies time-based throttling to limit update frequency - * - Thread-safe actor implementation - * - Automatic flush mechanism for pending updates - * - * Usage Example: - * ```swift - * // For streaming text updates - * await UIUpdateOptimizer.shared.addUpdate(newText) { batchedContent in - * // Update UI with batched content - * textView.text = batchedContent - * } - * - * // Force flush remaining updates when stream ends - * await UIUpdateOptimizer.shared.forceFlush { finalContent in - * textView.text = finalContent - * } - * ``` - * - * Configuration: - * - batchSize: Number of updates to batch before triggering immediate flush (default: 5) - * - flushInterval: Time interval in seconds between automatic flushes (default: 0.03s / 30ms) - */ +/// UIUpdateOptimizer - A utility for batching and throttling UI updates to improve performance +/// +/// This actor-based optimizer helps reduce the frequency of UI updates by batching multiple +/// updates together and applying throttling mechanisms. It's particularly useful for scenarios +/// like streaming text updates, real-time data feeds, or any situation where frequent UI +/// updates might cause performance issues. +/// +/// Key Features: +/// - Batches multiple updates into a single operation +/// - Applies time-based throttling to limit update frequency +/// - Thread-safe actor implementation +/// - Automatic flush mechanism for pending updates +/// +/// Usage Example: +/// ```swift +/// // For streaming text updates +/// await UIUpdateOptimizer.shared.addUpdate(newText) { batchedContent in +/// // Update UI with batched content +/// textView.text = batchedContent +/// } +/// +/// // Force flush remaining updates when stream ends +/// await UIUpdateOptimizer.shared.forceFlush { finalContent in +/// textView.text = finalContent +/// } +/// ``` +/// +/// Configuration: +/// - batchSize: Number of updates to batch before triggering immediate flush (default: 5) +/// - flushInterval: Time interval in seconds between automatic flushes (default: 0.03s / 30ms) actor UIUpdateOptimizer { static let shared = UIUpdateOptimizer() @@ -53,16 +51,14 @@ actor UIUpdateOptimizer { private init() {} - /** - * Adds a content update to the pending queue - * - * Updates are either flushed immediately if batch size or time threshold is reached, - * or scheduled for delayed flushing to optimize performance. - * - * - Parameters: - * - content: The content string to add to the update queue - * - completion: Callback executed with the batched content when flushed - */ + /// Adds a content update to the pending queue + /// + /// Updates are either flushed immediately if batch size or time threshold is reached, + /// or scheduled for delayed flushing to optimize performance. + /// + /// - Parameters: + /// - content: The content string to add to the update queue + /// - completion: Callback executed with the batched content when flushed func addUpdate(_ content: String, completion: @escaping (String) -> Void) { pendingUpdates.append(content) @@ -78,14 +74,12 @@ actor UIUpdateOptimizer { } } - /** - * Schedules a delayed flush operation - * - * Cancels any existing scheduled flush and creates a new one to avoid - * excessive flush operations while maintaining responsiveness. - * - * - Parameter completion: Callback to execute when flush occurs - */ + /// Schedules a delayed flush operation + /// + /// Cancels any existing scheduled flush and creates a new one to avoid + /// excessive flush operations while maintaining responsiveness. + /// + /// - Parameter completion: Callback to execute when flush occurs private func scheduleFlush(completion: @escaping (String) -> Void) { // Cancel previous scheduled flush to avoid redundant operations flushTask?.cancel() @@ -99,14 +93,12 @@ actor UIUpdateOptimizer { } } - /** - * Flushes all pending updates immediately - * - * Combines all pending updates into a single string and executes the completion - * callback on the main actor thread for UI updates. - * - * - Parameter completion: Callback executed with the combined content - */ + /// Flushes all pending updates immediately + /// + /// Combines all pending updates into a single string and executes the completion + /// callback on the main actor thread for UI updates. + /// + /// - Parameter completion: Callback executed with the combined content private func flushUpdates(completion: @escaping (String) -> Void) { guard !pendingUpdates.isEmpty else { return } @@ -119,15 +111,13 @@ actor UIUpdateOptimizer { } } - /** - * Forces immediate flush of any remaining pending updates - * - * This method should be called when you need to ensure all pending updates - * are processed immediately, such as when a stream ends or the view is about - * to disappear. - * - * - Parameter completion: Callback executed with any remaining content - */ + /// Forces immediate flush of any remaining pending updates + /// + /// This method should be called when you need to ensure all pending updates + /// are processed immediately, such as when a stream ends or the view is about + /// to disappear. + /// + /// - Parameter completion: Callback executed with any remaining content func forceFlush(completion: @escaping (String) -> Void) { if !pendingUpdates.isEmpty { flushUpdates(completion: completion) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMMessageTextView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMMessageTextView.swift index 8682082c..c9db6ddc 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMMessageTextView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMMessageTextView.swift @@ -8,61 +8,59 @@ import SwiftUI import MarkdownUI -/** - * LLMMessageTextView - A specialized text view designed for LLM chat messages with typewriter animation - * - * This SwiftUI component provides an enhanced text display specifically designed for AI chat applications. - * It supports both plain text and Markdown rendering with an optional typewriter animation effect - * that creates a dynamic, engaging user experience during AI response streaming. - * - * Key Features: - * - Typewriter animation for streaming AI responses - * - Markdown support with custom styling - * - Smart animation control based on message type and content length - * - Automatic animation management with proper cleanup - * - Performance-optimized character-by-character rendering - * - * Usage Examples: - * - * 1. Basic AI Message with Typewriter Effect: - * ```swift - * LLMMessageTextView( - * text: "Hello! This is an AI response with typewriter animation.", - * messageUseMarkdown: false, - * messageId: "msg_001", - * isAssistantMessage: true, - * isStreamingMessage: true - * ) - * ``` - * - * 2. Markdown Message with Custom Styling: - * ```swift - * LLMMessageTextView( - * text: "**Bold text** and *italic text* with `code blocks`", - * messageUseMarkdown: true, - * messageId: "msg_002", - * isAssistantMessage: true, - * isStreamingMessage: true - * ) - * ``` - * - * 3. User Message (No Animation): - * ```swift - * LLMMessageTextView( - * text: "This is a user message", - * messageUseMarkdown: false, - * messageId: "msg_003", - * isAssistantMessage: false, - * isStreamingMessage: false - * ) - * ``` - * - * Animation Configuration: - * - typingSpeed: 0.015 seconds per character (adjustable) - * - chunkSize: 1 character per animation frame - * - Minimum text length for animation: 5 characters - * - Auto-cleanup on view disappear or streaming completion - */ +/// LLMMessageTextView - A specialized text view designed for LLM chat messages with typewriter animation +/// +/// This SwiftUI component provides an enhanced text display specifically designed for AI chat applications. +/// It supports both plain text and Markdown rendering with an optional typewriter animation effect +/// that creates a dynamic, engaging user experience during AI response streaming. +/// +/// Key Features: +/// - Typewriter animation for streaming AI responses +/// - Markdown support with custom styling +/// - Smart animation control based on message type and content length +/// - Automatic animation management with proper cleanup +/// - Performance-optimized character-by-character rendering +/// +/// Usage Examples: +/// +/// 1. Basic AI Message with Typewriter Effect: +/// ```swift +/// LLMMessageTextView( +/// text: "Hello! This is an AI response with typewriter animation.", +/// messageUseMarkdown: false, +/// messageId: "msg_001", +/// isAssistantMessage: true, +/// isStreamingMessage: true +/// ) +/// ``` +/// +/// 2. Markdown Message with Custom Styling: +/// ```swift +/// LLMMessageTextView( +/// text: "**Bold text** and *italic text* with `code blocks`", +/// messageUseMarkdown: true, +/// messageId: "msg_002", +/// isAssistantMessage: true, +/// isStreamingMessage: true +/// ) +/// ``` +/// +/// 3. User Message (No Animation): +/// ```swift +/// LLMMessageTextView( +/// text: "This is a user message", +/// messageUseMarkdown: false, +/// messageId: "msg_003", +/// isAssistantMessage: false, +/// isStreamingMessage: false +/// ) +/// ``` +/// +/// Animation Configuration: +/// - typingSpeed: 0.015 seconds per character (adjustable) +/// - chunkSize: 1 character per animation frame +/// - Minimum text length for animation: 5 characters +/// - Auto-cleanup on view disappear or streaming completion struct LLMMessageTextView: View { let text: String? let messageUseMarkdown: Bool @@ -194,14 +192,12 @@ struct LLMMessageTextView: View { } } - /** - * Handles text content changes during streaming - * - * This method intelligently manages animation continuation, restart, or direct display - * based on the relationship between old and new text content. - * - * - Parameter newText: The updated text content - */ + /// Handles text content changes and manages animation state + /// + /// This method intelligently manages animation continuation, restart, or direct display + /// based on the relationship between old and new text content. + /// + /// - Parameter newText: The updated text content private func handleTextChange(_ newText: String?) { guard let newText = newText else { displayedText = "" @@ -225,24 +221,20 @@ struct LLMMessageTextView: View { } } - /** - * Initiates typewriter animation for the given text - * - * - Parameter text: The text to animate - */ + /// Initiates typewriter animation for the given text + /// + /// - Parameter text: The text to animate private func startTypewriterAnimation(for text: String) { displayedText = "" continueTypewriterAnimation(with: text) } - /** - * Continues or resumes typewriter animation - * - * This method sets up a timer-based animation that progressively reveals - * characters at the configured typing speed. - * - * - Parameter text: The complete text to animate - */ + /// Continues or resumes typewriter animation + /// + /// This method sets up a timer-based animation that progressively reveals + /// characters at the configured typing speed. + /// + /// - Parameter text: The complete text to animate private func continueTypewriterAnimation(with text: String) { guard displayedText.count < text.count else { return } @@ -255,25 +247,21 @@ struct LLMMessageTextView: View { } } - /** - * Restarts typewriter animation with new content - * - * - Parameter text: The new text to animate - */ + /// Restarts typewriter animation with new content + /// + /// - Parameter text: The new text to animate private func restartTypewriterAnimation(with text: String) { stopAnimation() displayedText = "" startTypewriterAnimation(for: text) } - /** - * Appends the next character(s) to the displayed text - * - * This method is called by the animation timer to progressively reveal - * text characters. It handles proper string indexing and animation completion. - * - * - Parameter text: The source text to extract characters from - */ + /// Appends the next character(s) to the displayed text + /// + /// This method is called by the animation timer to progressively reveal + /// text characters. It handles proper string indexing and animation completion. + /// + /// - Parameter text: The source text to extract characters from private func appendNextCharacters(from text: String) { let currentLength = displayedText.count guard currentLength < text.count else { @@ -293,12 +281,10 @@ struct LLMMessageTextView: View { } } - /** - * Stops and cleans up the typewriter animation - * - * This method should be called when animation is no longer needed - * to prevent memory leaks and unnecessary timer execution. - */ + /// Stops and cleans up the typewriter animation + /// + /// This method should be called when animation is no longer needed + /// to prevent memory leaks and unnecessary timer execution. private func stopAnimation() { animationTimer?.invalidate() animationTimer = nil diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift index 90226403..c54a9131 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift @@ -8,11 +8,9 @@ import Foundation import Darwin -/** - * Helper class for processing and formatting benchmark test results. - * Provides statistical analysis, formatting utilities, and device information - * for benchmark result display and sharing. - */ +/// Helper class for processing and formatting benchmark test results. +/// Provides statistical analysis, formatting utilities, and device information +/// for benchmark result display and sharing. class BenchmarkResultsHelper { static let shared = BenchmarkResultsHelper() diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkErrorCode.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkErrorCode.swift index 8762c212..61f4959e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkErrorCode.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkErrorCode.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Enumeration of possible error codes that can occur during benchmark execution. - * Provides specific error identification for different failure scenarios. - */ +/// Enumeration of possible error codes that can occur during benchmark execution. +/// Provides specific error identification for different failure scenarios. enum BenchmarkErrorCode: Int { case benchmarkFailedUnknown = 30 case testInstanceFailed = 40 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkProgress.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkProgress.swift index 9f882aa2..ba508af0 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkProgress.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkProgress.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Structure containing detailed progress information for benchmark execution. - * Provides real-time metrics including timing data and performance statistics. - */ +/// Structure containing detailed progress information for benchmark execution. +/// Provides real-time metrics including timing data and performance statistics. struct BenchmarkProgress { let progress: Int // 0-100 let statusMessage: String diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResult.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResult.swift index c1bb6823..35f5d229 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResult.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResult.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Structure containing the results of a completed benchmark test. - * Encapsulates test instance data along with success status and error information. - */ +/// Structure containing the results of a completed benchmark test. +/// Encapsulates test instance data along with success status and error information. struct BenchmarkResult { let testInstance: TestInstance let success: Bool diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift index 361e943f..3f57e5a1 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Structure containing comprehensive benchmark results for display and sharing. - * Aggregates test results, memory usage, and metadata for result presentation. - */ +/// Structure containing comprehensive benchmark results for display and sharing. +/// Aggregates test results, memory usage, and metadata for result presentation. struct BenchmarkResults { let modelDisplayName: String let maxMemoryKb: Int64 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift index 766639e6..89397f8c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Structure containing comprehensive statistical analysis of benchmark results. - * Aggregates performance metrics, configuration details, and test summary information. - */ +/// Structure containing comprehensive statistical analysis of benchmark results. +/// Aggregates performance metrics, configuration details, and test summary information. struct BenchmarkStatistics { let configText: String let prefillStats: SpeedStatistics? diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/DeviceInfoHelper.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/DeviceInfoHelper.swift index a8bdebb2..5335281e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/DeviceInfoHelper.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/DeviceInfoHelper.swift @@ -8,10 +8,8 @@ import Foundation import UIKit -/** - * Helper class for retrieving device information including model identification - * and system details. Provides device-specific information for benchmark results. - */ +/// Helper class for retrieving device information including model identification +/// and system details. Provides device-specific information for benchmark results. class DeviceInfoHelper { static let shared = DeviceInfoHelper() diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelItem.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelItem.swift index 646fe47b..c6758aad 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelItem.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelItem.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Structure representing a model item with download state information. - * Used for tracking model availability and download progress in the benchmark interface. - */ +/// Structure representing a model item with download state information. +/// Used for tracking model availability and download progress in the benchmark interface. struct ModelItem: Identifiable, Equatable { let id = UUID() let modelId: String diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelListManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelListManager.swift index 1dd57f34..9a22494b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelListManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ModelListManager.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Manager class for integrating with ModelListViewModel to provide - * downloaded models for benchmark testing. - */ +/// Manager class for integrating with ModelListViewModel to provide +/// downloaded models for benchmark testing. class ModelListManager { static let shared = ModelListManager() diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ProgressType.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ProgressType.swift index ed0588c4..4083b55a 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ProgressType.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/ProgressType.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Enumeration representing different stages of benchmark execution progress. - * Used to track and display the current state of benchmark operations. - */ +/// Enumeration representing different stages of benchmark execution progress. +/// Used to track and display the current state of benchmark operations. enum ProgressType: Int, CaseIterable { case unknown = 0 case initializing diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/RuntimeParameters.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/RuntimeParameters.swift index 6d8aeaad..890e82ce 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/RuntimeParameters.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/RuntimeParameters.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Configuration parameters for benchmark runtime environment. - * Defines hardware and execution settings for benchmark tests. - */ +/// Configuration parameters for benchmark runtime environment. +/// Defines hardware and execution settings for benchmark tests. struct RuntimeParameters { let backends: [Int] let threads: [Int] diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/SpeedStatistics.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/SpeedStatistics.swift index d875a4f1..8c5b2071 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/SpeedStatistics.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/SpeedStatistics.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Structure containing statistical analysis of benchmark speed metrics. - * Provides average, standard deviation, and descriptive label for performance data. - */ +/// Structure containing statistical analysis of benchmark speed metrics. +/// Provides average, standard deviation, and descriptive label for performance data. struct SpeedStatistics { let average: Double let stdev: Double diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestInstance.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestInstance.swift index 56b4467f..fc4dbd18 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestInstance.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestInstance.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Observable class representing a single benchmark test instance. - * Contains test configuration parameters and stores timing results. - */ +/// Observable class representing a single benchmark test instance. +/// Contains test configuration parameters and stores timing results. class TestInstance: ObservableObject, Identifiable { let id = UUID() let modelConfigFile: String diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestParameters.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestParameters.swift index c7686e01..f1b29abb 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestParameters.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/TestParameters.swift @@ -7,10 +7,8 @@ import Foundation -/** - * Configuration parameters for benchmark test execution. - * Defines test scenarios including prompt sizes, generation lengths, and repetition counts. - */ +/// Configuration parameters for benchmark test execution. +/// Defines test scenarios including prompt sizes, generation lengths, and repetition counts. struct TestParameters { let nPrompt: [Int] let nGenerate: [Int] diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Services/BenchmarkService.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Services/BenchmarkService.swift index 64415fb8..4e763300 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Services/BenchmarkService.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Services/BenchmarkService.swift @@ -7,21 +7,17 @@ import Foundation -/** - * Protocol defining callback methods for benchmark execution events. - * Provides progress updates, completion notifications, and error handling. - */ +/// Protocol defining callback methods for benchmark execution events. +/// Provides progress updates, completion notifications, and error handling. protocol BenchmarkCallback: AnyObject { func onProgress(_ progress: BenchmarkProgress) func onComplete(_ result: BenchmarkResult) func onBenchmarkError(_ errorCode: Int, _ message: String) } -/** - * Singleton service class responsible for managing benchmark operations. - * Coordinates with LLMInferenceEngineWrapper to execute performance tests - * and provides real-time progress updates through callback mechanisms. - */ +/// Singleton service class responsible for managing benchmark operations. +/// Coordinates with LLMInferenceEngineWrapper to execute performance tests +/// and provides real-time progress updates through callback mechanisms. class BenchmarkService: ObservableObject { // MARK: - Singleton & Properties diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift index 53a3c8cd..829b999c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift @@ -9,11 +9,9 @@ import Foundation import SwiftUI import Combine -/** - * ViewModel for managing benchmark operations including model selection, test execution, - * progress tracking, and result management. Handles communication with BenchmarkService - * and provides UI state management for the benchmark interface. - */ +/// ViewModel for managing benchmark operations including model selection, test execution, +/// progress tracking, and result management. Handles communication with BenchmarkService +/// and provides UI state management for the benchmark interface. @MainActor class BenchmarkViewModel: ObservableObject { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/MetricCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/MetricCard.swift index c2ad6200..19c6f8ec 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/MetricCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/MetricCard.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Reusable metric display card component. - * Shows performance metrics with icon, title, and value in a compact format. - */ +/// Reusable metric display card component. +/// Shows performance metrics with icon, title, and value in a compact format. struct MetricCard: View { let title: String let value: String @@ -55,4 +53,4 @@ struct MetricCard: View { MetricCard(title: "Memory", value: "1.2 GB", icon: "memorychip") } .padding() -} \ No newline at end of file +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift index a7563e55..0379655c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Reusable model selection card component for benchmark interface. - * Provides dropdown menu for model selection and start/stop controls. - */ +/// Reusable model selection card component for benchmark interface. +/// Provides dropdown menu for model selection and start/stop controls. struct ModelSelectionCard: View { @ObservedObject var viewModel: BenchmarkViewModel diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift index 7b355c83..0fda06fa 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Enhanced performance metric display component. - * Shows detailed performance metrics with gradient backgrounds, icons, and custom colors. - */ +/// Enhanced performance metric display component. +/// Shows detailed performance metrics with gradient backgrounds, icons, and custom colors. struct PerformanceMetricView: View { let icon: String let title: String diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift index 6f8cc1d6..d5a4e1bd 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Reusable progress tracking card component for benchmark interface. - * Displays test progress with detailed metrics and visual indicators. - */ +/// Reusable progress tracking card component for benchmark interface. +/// Displays test progress with detailed metrics and visual indicators. struct ProgressCard: View { let progress: BenchmarkProgress? diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift index 37f01980..44a342a3 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Reusable results display card component for benchmark interface. - * Shows comprehensive benchmark results with performance metrics and statistics. - */ +/// Reusable results display card component for benchmark interface. +/// Shows comprehensive benchmark results with performance metrics and statistics. struct ResultsCard: View { let results: BenchmarkResults diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift index 2ded0a00..251be813 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Reusable status display card component for benchmark interface. - * Shows status messages and updates to provide user feedback. - */ +/// Reusable status display card component for benchmark interface. +/// Shows status messages and updates to provide user feedback. struct StatusCard: View { let statusMessage: String diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift index d8dbd483..3b69d115 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift @@ -7,10 +7,8 @@ import SwiftUI -/** - * Main benchmark view that provides interface for running performance tests on ML models. - * Features include model selection, progress tracking, and results visualization. - */ +/// Main benchmark view that provides interface for running performance tests on ML models. +/// Features include model selection, progress tracking, and results visualization. struct BenchmarkView: View { @StateObject private var viewModel = BenchmarkViewModel() diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift index b6413c21..53858e26 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift @@ -10,60 +10,72 @@ import Network // MARK: - Dynamic Concurrency Configuration -/** - Dynamic concurrency control manager - intelligently adjusts concurrency parameters - based on chunk count and network conditions - - Usage Examples: - - ```swift - // 1. Create dynamic concurrency manager - let concurrencyManager = DynamicConcurrencyManager() - - // 2. Get download strategy for file - let fileSize: Int64 = 240 * 1024 * 1024 // 240MB - let strategy = await concurrencyManager.recommendDownloadStrategy(fileSize: fileSize) - - print(strategy.description) - // Output might be: - // Download Strategy: - // - Use Chunking: Yes - // - Chunk Size: 10MB - // - Chunk Count: 24 - // - Concurrency: 6 (24 chunks / 4 ideal chunks per concurrency = 6) - // - Network Type: wifi - // - Device Performance: high - - // 3. Create DownloadConfig using strategy - let dynamicConfig = DownloadConfig( - maxConcurrentDownloads: strategy.concurrency, - chunkSize: strategy.chunkSize, - largeFileThreshold: strategy.chunkSize * 2, - maxRetries: 3, - retryDelay: 2.0 - ) - - Best Practices: - - 1. **Intelligent Concurrency Control**: - - For 24 chunks: use 6-8 concurrent downloads (instead of fixed 3) - - For 4 chunks: use 2-3 concurrent downloads - - For 1 chunk: use 1 concurrent download - - 2. **Network Adaptation**: - - WiFi: larger chunk size and more concurrency - - 4G: medium chunk size and concurrency - - 3G: small chunk size and less concurrency - - 3. **Device Performance Consideration**: - - High-performance devices: can handle more concurrency - - Low-performance devices: reduce concurrency to avoid lag - - 4. **Dynamic Adjustment**: - - Automatically adjust strategy when network status changes - - Dynamically optimize based on actual download performance - */ +/// Dynamic concurrency control manager - intelligently adjusts concurrency parameters +/// based on chunk count and network conditions +/// +/// Usage Examples: +/// +/// ```swift +/// // 1. Create dynamic concurrency manager +/// let concurrencyManager = DynamicConcurrencyManager() +/// +/// // 2. Get download strategy for file +/// let fileSize: Int64 = 240 * 1024 * 1024 // 240MB +/// let strategy = await concurrencyManager.recommendDownloadStrategy(fileSize: fileSize) +/// +/// print(strategy.description) +/// // Output might be: +/// // Download Strategy: +/// // - Use Chunking: Yes +/// // - Chunk Size: 10MB +/// // - Chunk Count: 24 +/// // - Concurrency: 6 (24 chunks / 4 ideal chunks per concurrency = 6) +/// // - Network Type: wifi +/// // - Device Performance: high +/// +/// // 3. Create DownloadConfig using strategy +/// let dynamicConfig = DownloadConfig( +/// maxConcurrentDownloads: strategy.concurrency, +/// chunkSize: strategy.chunkSize, +/// largeFileThreshold: strategy.chunkSize * 2, +/// maxRetries: 3, +/// retryDelay: 2.0 +/// ) +/// ``` +/// +/// Best Practices: +/// +/// 1. **Intelligent Concurrency Control**: +/// - For 24 chunks: use 6-8 concurrent downloads (instead of fixed 3) +/// - For 4 chunks: use 2-3 concurrent downloads +/// - For 1 chunk: use 1 concurrent download +/// +/// 2. **Network Adaptation**: +/// - WiFi: larger chunk size and more concurrency +/// - 4G: medium chunk size and concurrency +/// - 3G: small chunk size and less concurrency +/// +/// 3. **Device Performance Consideration**: +/// - High-performance devices: can handle more concurrency +/// - Low-performance devices: reduce concurrency to avoid lag +/// +/// 4. **Dynamic Adjustment**: +/// - Automatically adjust strategy when network status changes +/// - Dynamically optimize based on actual download performance +/// Configuration for dynamic concurrency management +/// +/// This structure defines the parameters used to dynamically adjust download concurrency +/// based on network conditions and device performance characteristics. +/// +/// - Parameters: +/// - baseConcurrency: The baseline number of concurrent downloads +/// - maxConcurrency: The maximum number of concurrent downloads allowed +/// - minConcurrency: The minimum number of concurrent downloads to maintain +/// - idealChunksPerConcurrency: The ideal number of chunks per concurrent download +/// - networkTypeMultiplier: Multiplier for network type adjustments +/// - devicePerformanceMultiplier: Multiplier for device performance adjustments +/// - largeFileThreshold: File size threshold for enabling chunked downloads public struct DynamicConcurrencyConfig { let baseConcurrency: Int @@ -93,12 +105,19 @@ public struct DynamicConcurrencyConfig { // MARK: - Network Type Detection +/// Network type classification for optimization +/// +/// Categorizes different network connection types to enable appropriate +/// download strategy selection and performance optimization. public enum NetworkType { case wifi case cellular case lowBandwidth case unknown + /// Multiplier for adjusting concurrency based on device performance + /// + /// - Returns: A multiplier value used to scale the base concurrency level var concurrencyMultiplier: Double { switch self { case .wifi: return 1.5 @@ -120,6 +139,10 @@ public enum NetworkType { // MARK: - Device Performance Detection +/// Device performance classification +/// +/// Categorizes device performance capabilities to optimize download strategies +/// based on available processing power and memory resources. public enum DevicePerformance { case high case medium @@ -133,6 +156,9 @@ public enum DevicePerformance { } } + /// Detect the current device's performance level + /// + /// - Returns: The detected device performance classification static func detect() -> DevicePerformance { let processInfo = ProcessInfo.processInfo let physicalMemory = processInfo.physicalMemory @@ -192,6 +218,9 @@ public actor DynamicConcurrencyManager { } /// Calculate optimal concurrency based on chunk count and current network conditions + /// + /// - Parameter chunkCount: The number of chunks to download + /// - Returns: The recommended number of concurrent downloads public func calculateOptimalConcurrency(chunkCount: Int) -> Int { // Base calculation: based on chunk count and ideal ratio let baseConcurrency = max(1, min(chunkCount / config.idealChunksPerConcurrency, config.baseConcurrency)) @@ -209,11 +238,15 @@ public actor DynamicConcurrencyManager { } /// Get current network status information + /// + /// - Returns: A tuple containing the current network type and device performance public func getNetworkInfo() -> (type: NetworkType, performance: DevicePerformance) { return (currentNetworkType, devicePerformance) } - /// Get recommended chunk size + /// Get recommended chunk size based on current network conditions and device performance + /// + /// - Returns: The recommended chunk size in bytes public func recommendedChunkSize() -> Int64 { let baseChunkSize = currentNetworkType.recommendedChunkSize let performanceMultiplier = devicePerformance.concurrencyMultiplier @@ -222,6 +255,9 @@ public actor DynamicConcurrencyManager { } /// Recommend download strategy based on file size and network conditions + /// + /// - Parameter fileSize: The size of the file being downloaded + /// - Returns: A complete download strategy configuration public func recommendDownloadStrategy(fileSize: Int64) -> DownloadStrategy { let chunkSize = recommendedChunkSize() let shouldUseChunking = fileSize > config.largeFileThreshold @@ -245,6 +281,10 @@ public actor DynamicConcurrencyManager { // MARK: - Download Strategy +/// Download strategy configuration +/// +/// Contains all the parameters needed to optimize download performance +/// based on current network and device conditions. public struct DownloadStrategy { let shouldUseChunking: Bool let chunkSize: Int64 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift index 6e6e1c7a..1429043f 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift @@ -8,34 +8,32 @@ import Hub import Foundation -/** - * ModelClient - Unified model download and management client - * - * This class provides a centralized interface for downloading models from multiple sources - * including ModelScope, Modeler, and HuggingFace platforms. It handles source-specific - * download logic, progress tracking, and error handling. - * - * Key Features: - * - Multi-platform support (ModelScope, Modeler, HuggingFace) - * - Progress tracking with throttling to prevent UI stuttering - * - Automatic fallback to local mock data for development - * - Dependency injection for download managers - * - Cancellation support for ongoing downloads - * - * Architecture: - * - Uses factory pattern for download manager creation - * - Implements strategy pattern for different download sources - * - Provides async/await interface for modern Swift concurrency - * - * Usage: - * ```swift - * let client = ModelClient() - * let models = try await client.getModelInfo() - * try await client.downloadModel(model: selectedModel) { progress in - * print("Download progress: \(progress * 100)%") - * } - * ``` - */ +/// ModelClient - Unified model download and management client +/// +/// This class provides a centralized interface for downloading models from multiple sources +/// including ModelScope, Modeler, and HuggingFace platforms. It handles source-specific +/// download logic, progress tracking, and error handling. +/// +/// Key Features: +/// - Multi-platform support (ModelScope, Modeler, HuggingFace) +/// - Progress tracking with throttling to prevent UI stuttering +/// - Automatic fallback to local mock data for development +/// - Dependency injection for download managers +/// - Cancellation support for ongoing downloads +/// +/// Architecture: +/// - Uses factory pattern for download manager creation +/// - Implements strategy pattern for different download sources +/// - Provides async/await interface for modern Swift concurrency +/// +/// Usage: +/// ```swift +/// let client = ModelClient() +/// let models = try await client.getModelInfo() +/// try await client.downloadModel(model: selectedModel) { progress in +/// print("Download progress: \(progress * 100)%") +/// } +/// ``` class ModelClient { // MARK: - Properties @@ -60,25 +58,21 @@ class ModelClient { } }() - /** - * Creates a ModelClient with dependency injection for download manager - * - * @param downloadManagerFactory Factory for creating download managers. - * Defaults to DefaultModelDownloadManagerFactory - */ + /// Creates a ModelClient with dependency injection for download manager + /// + /// - Parameter downloadManagerFactory: Factory for creating download managers. + /// Defaults to DefaultModelDownloadManagerFactory init(downloadManagerFactory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory()) { self.downloadManagerFactory = downloadManagerFactory } - /** - * Retrieves model information from the configured API endpoint - * - * This method fetches the latest model catalog from the network API. - * In debug mode or when network fails, it falls back to local mock data. - * - * @return TBDataResponse containing the model catalog - * @throws NetworkError if both network request and local fallback fail - */ + /// Retrieves model information from the configured API endpoint + /// + /// This method fetches the latest model catalog from the network API. + /// In debug mode or when network fails, it falls back to local mock data. + /// + /// - Returns: TBDataResponse containing the model catalog + /// - Throws: NetworkError if both network request and local fallback fail func getModelInfo() async throws -> TBDataResponse { if useLocalMockData { // Debug mode: use local mock data @@ -95,11 +89,9 @@ class ModelClient { } } - /** - * Fetches data from the network API with fallback to local mock data - * - * @throws NetworkError if both network request and local fallback fail - */ + /// Fetches data from the network API with fallback to local mock data + /// + /// - Throws: NetworkError if both network request and local fallback fail private func fetchDataFromAliAPI() async throws -> TBDataResponse { do { guard let url = URL(string: AliCDNURL) else { @@ -130,13 +122,12 @@ class ModelClient { } } - /** - * Downloads a model from the selected source with progress tracking - * - * @param model The ModelInfo object containing model details - * @param progress Progress callback that receives download progress (0.0 to 1.0) - * @throws Various network or file system errors - */ + /// Downloads a model from the selected source with progress tracking + /// + /// - Parameters: + /// - model: The ModelInfo object containing model details + /// - progress: Progress callback that receives download progress (0.0 to 1.0) + /// - Throws: Various network or file system errors func downloadModel(model: ModelInfo, progress: @escaping (Double) -> Void) async throws { switch ModelSourceManager.shared.selectedSource { @@ -149,9 +140,7 @@ class ModelClient { } } - /** - * Cancels the current download operation - */ + /// Cancels the current download operation func cancelDownload() async { switch ModelSourceManager.shared.selectedSource { case .modelScope, .modeler: @@ -163,13 +152,12 @@ class ModelClient { } } - /** - * Downloads model from ModelScope/Modler platform - * - * @param model The ModelInfo object to download - * @param progress Progress callback for download updates - * @throws Download or network related errors - */ + /// Downloads model from ModelScope/Modler platform + /// + /// - Parameters: + /// - model: The ModelInfo object to download + /// - progress: Progress callback for download updates + /// - Throws: Download or network related errors private func downloadFromModelScope(_ model: ModelInfo, source: ModelSource, progress: @escaping (Double) -> Void) async throws { @@ -198,16 +186,15 @@ class ModelClient { } } - /** - * Downloads model from HuggingFace platform with optimized progress updates - * - * This method implements throttling to prevent UI stuttering by limiting - * progress update frequency and filtering out minor progress changes. - * - * @param model The ModelInfo object to download - * @param progress Progress callback for download updates - * @throws Download or network related errors - */ + /// Downloads model from HuggingFace platform with optimized progress updates + /// + /// This method implements throttling to prevent UI stuttering by limiting + /// progress update frequency and filtering out minor progress changes. + /// + /// - Parameters: + /// - model: The ModelInfo object to download + /// - progress: Progress callback for download updates + /// - Throws: Download or network related errors private func downloadFromHuggingFace(_ model: ModelInfo, progress: @escaping (Double) -> Void) async throws { let repo = Hub.Repo(id: model.id) @@ -251,18 +238,16 @@ class ModelClient { } -/** - * NetworkError - Enumeration of network-related errors - * - * This enum defines the various error conditions that can occur during - * network operations and model downloads. - * - * Error Cases: - * - invalidResponse: HTTP response is invalid or has non-200 status code - * - invalidData: Data received is corrupted or cannot be decoded - * - downloadFailed: Download operation failed due to network or file system issues - * - unknown: Unexpected error occurred during network operation - */ +/// NetworkError - Enumeration of network-related errors +/// +/// This enum defines the various error conditions that can occur during +/// network operations and model downloads. +/// +/// Error Cases: +/// - invalidResponse: HTTP response is invalid or has non-200 status code +/// - invalidData: Data received is corrupted or cannot be decoded +/// - downloadFailed: Download operation failed due to network or file system issues +/// - unknown: Unexpected error occurred during network operation enum NetworkError: Error { case invalidResponse case invalidData diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift index b50ea793..8e006f3f 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift @@ -7,49 +7,47 @@ import Foundation -/** - * ModelDownloadManager - Advanced concurrent model download manager - * - * This actor-based download manager provides high-performance, resumable downloads - * with intelligent chunking, dynamic concurrency optimization, and comprehensive - * error handling for model files from various sources. - * - * Key Features: - * - Concurrent downloads with dynamic concurrency adjustment - * - Intelligent file chunking for large files (>50MB) - * - Resume capability with partial download preservation - * - Exponential backoff retry mechanism - * - Real-time progress tracking with throttling - * - Memory-efficient streaming downloads - * - Thread-safe operations using Swift Actor model - * - * Architecture: - * - Uses URLSession for network operations - * - Implements semaphore-based concurrency control - * - Supports both direct and chunked download strategies - * - Maintains download state persistence - * - * Performance Optimizations: - * - Dynamic chunk size calculation based on network conditions - * - Optimal concurrency level determination - * - Progress update throttling to prevent UI blocking - * - Temporary file management for resume functionality - * - * Usage: - * ```swift - * let manager = ModelDownloadManager( - * repoPath: "owner/model-name", - * source: .modelScope - * ) - * try await manager.downloadModel( - * to: "models", - * modelId: "example-model", - * modelName: "ExampleModel" - * ) { progress in - * print("Progress: \(progress * 100)%") - * } - * ``` - */ +/// ModelDownloadManager - Advanced concurrent model download manager +/// +/// This actor-based download manager provides high-performance, resumable downloads +/// with intelligent chunking, dynamic concurrency optimization, and comprehensive +/// error handling for model files from various sources. +/// +/// Key Features: +/// - Concurrent downloads with dynamic concurrency adjustment +/// - Intelligent file chunking for large files (>50MB) +/// - Resume capability with partial download preservation +/// - Exponential backoff retry mechanism +/// - Real-time progress tracking with throttling +/// - Memory-efficient streaming downloads +/// - Thread-safe operations using Swift Actor model +/// +/// Architecture: +/// - Uses URLSession for network operations +/// - Implements semaphore-based concurrency control +/// - Supports both direct and chunked download strategies +/// - Maintains download state persistence +/// +/// Performance Optimizations: +/// - Dynamic chunk size calculation based on network conditions +/// - Optimal concurrency level determination +/// - Progress update throttling to prevent UI blocking +/// - Temporary file management for resume functionality +/// +/// Usage: +/// ```swift +/// let manager = ModelDownloadManager( +/// repoPath: "owner/model-name", +/// source: .modelScope +/// ) +/// try await manager.downloadModel( +/// to: "models", +/// modelId: "example-model", +/// modelName: "ExampleModel" +/// ) { progress in +/// print("Progress: \(progress * 100)%") +/// } +/// ``` @available(iOS 13.4, macOS 10.15, *) public actor ModelDownloadManager: ModelDownloadManagerProtocol { @@ -76,16 +74,15 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { // MARK: - Initialization - /** - * Initializes a new ModelDownloadManager with configurable parameters - * - * @param repoPath Repository path in format "owner/model-name" - * @param config Download configuration including retry and chunk settings - * @param sessionConfig URLSession configuration for network requests - * @param enableLogging Whether to enable detailed logging - * @param source Model source platform (ModelScope, Modeler, etc.) - * @param concurrencyConfig Dynamic concurrency management configuration - */ + /// Initializes a new ModelDownloadManager with configurable parameters + /// + /// - Parameters: + /// - repoPath: Repository path in format "owner/model-name" + /// - config: Download configuration including retry and chunk settings + /// - sessionConfig: URLSession configuration for network requests + /// - enableLogging: Whether to enable detailed logging + /// - source: Model source platform (ModelScope, Modeler, etc.) + /// - concurrencyConfig: Dynamic concurrency management configuration public init( repoPath: String, config: DownloadConfig = .default, @@ -109,19 +106,18 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { // MARK: - Public Methods - /** - * Downloads a complete model with all its files - * - * This method orchestrates the entire download process including file discovery, - * task preparation, concurrent execution, and progress tracking. It supports - * resume functionality and handles various error conditions gracefully. - * - * @param destinationFolder Base folder for download (relative to Documents) - * @param modelId Unique identifier for the model - * @param modelName Display name used for folder creation - * @param progress Optional progress callback (0.0 to 1.0) - * @throws ModelScopeError for various download failures - */ + /// Downloads a complete model with all its files + /// + /// This method orchestrates the entire download process including file discovery, + /// task preparation, concurrent execution, and progress tracking. It supports + /// resume functionality and handles various error conditions gracefully. + /// + /// - Parameters: + /// - destinationFolder: Base folder for download (relative to Documents) + /// - modelId: Unique identifier for the model + /// - modelName: Display name used for folder creation + /// - progress: Optional progress callback (0.0 to 1.0) + /// - Throws: ModelScopeError for various download failures public func downloadModel( to destinationFolder: String = "", modelId: String, @@ -156,12 +152,10 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { ModelDownloadLogger.info("Download completed successfully") } - /** - * Cancels all ongoing download operations while preserving partial downloads - * - * This method gracefully stops all active downloads and preserves temporary - * files to enable resume functionality in future download attempts. - */ + /// Cancels all ongoing download operations while preserving partial downloads + /// + /// This method gracefully stops all active downloads and preserves temporary + /// files to enable resume functionality in future download attempts. public func cancelDownload() async { isCancelled = true @@ -177,16 +171,15 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { // MARK: - Private Methods - Task Preparation - /** - * Prepares download tasks by analyzing files and creating appropriate download strategies - * - * This method processes the file list and determines the optimal download approach - * for each file based on size, existing partial downloads, and configuration. - * - * @param files Array of ModelFile objects representing files to download - * @param destinationPath Target directory path for downloads - * @throws ModelScopeError for file system or processing errors - */ + /// Prepares download tasks by analyzing files and creating appropriate download strategies + /// + /// This method processes the file list and determines the optimal download approach + /// for each file based on size, existing partial downloads, and configuration. + /// + /// - Parameters: + /// - files: Array of ModelFile objects representing files to download + /// - destinationPath: Target directory path for downloads + /// - Throws: ModelScopeError for file system or processing errors private func prepareDownloadTasks( files: [ModelFile], destinationPath: String @@ -200,17 +193,16 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { ModelDownloadLogger.info("Prepared \(downloadQueue.count) download tasks, total size: \(progress.totalBytes) bytes") } - /** - * Processes individual files and creates corresponding download tasks - * - * Analyzes each file to determine if it needs chunked or direct download - * based on file size and configuration thresholds. Handles directory creation - * and recursive file processing for nested structures. - * - * @param files Array of ModelFile objects to process - * @param destinationPath Base destination path for downloads - * @throws ModelScopeError for file system or network errors - */ + /// Processes individual files and creates corresponding download tasks + /// + /// Analyzes each file to determine if it needs chunked or direct download + /// based on file size and configuration thresholds. Handles directory creation + /// and recursive file processing for nested structures. + /// + /// - Parameters: + /// - files: Array of ModelFile objects to process + /// - destinationPath: Base destination path for downloads + /// - Throws: ModelScopeError for file system or network errors private func processFiles( _ files: [ModelFile], destinationPath: String @@ -246,16 +238,14 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { } } - /** - * Creates chunked download information for large files - * - * Divides large files into optimal chunks for concurrent downloading, - * calculates resume offsets for existing partial downloads, and creates - * chunk metadata with temporary file locations. - * - * @param file ModelFile object representing the file to chunk - * @return Array of ChunkInfo objects containing chunk metadata - */ + /// Creates chunked download information for large files + /// + /// Divides large files into optimal chunks for concurrent downloading, + /// calculates resume offsets for existing partial downloads, and creates + /// chunk metadata with temporary file locations. + /// + /// - Parameter file: ModelFile object representing the file to chunk + /// - Returns: Array of ChunkInfo objects containing chunk metadata private func createChunks(for file: ModelFile) async -> [ChunkInfo] { let fileSize = Int64(file.size) @@ -306,15 +296,13 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { // MARK: - Download Execution - /** - * Executes download tasks with dynamic concurrency management - * - * Manages the concurrent execution of download tasks using semaphores - * and dynamic concurrency adjustment based on network performance. - * Handles both chunked and direct download strategies. - * - * @throws ModelScopeError if downloads fail or are cancelled - */ + /// Executes download tasks with dynamic concurrency management + /// + /// Manages the concurrent execution of download tasks using semaphores + /// and dynamic concurrency adjustment based on network performance. + /// Handles both chunked and direct download strategies. + /// + /// - Throws: ModelScopeError if downloads fail or are cancelled private func executeDownloads() async throws { await withTaskGroup(of: Void.self) { group in for task in downloadQueue { @@ -345,16 +333,14 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { } } - /** - * Downloads a file using chunked strategy with resume capability - * - * Handles the download of individual file chunks with retry logic, - * progress tracking, and automatic chunk merging upon completion. - * Uses optimal concurrency for chunk downloads. - * - * @param task DownloadTask containing chunk information and file metadata - * @throws ModelScopeError for network or file system errors - */ + /// Downloads a file using chunked strategy with resume capability + /// + /// Handles the download of individual file chunks with retry logic, + /// progress tracking, and automatic chunk merging upon completion. + /// Uses optimal concurrency for chunk downloads. + /// + /// - Parameter task: DownloadTask containing chunk information and file metadata + /// - Throws: ModelScopeError for network or file system errors private func downloadFileInChunks(task: DownloadTask) async throws { ModelDownloadLogger.info("Starting chunked download for: \(task.file.name)") @@ -397,17 +383,16 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { try await mergeChunks(task: task) } - /** - * Downloads a specific chunk with range requests and resume support - * - * Performs HTTP range request to download a specific portion of a file, - * with automatic resume from existing partial downloads and exponential - * backoff retry logic. - * - * @param chunk ChunkInfo containing chunk metadata and temporary file location - * @param file ModelFile object representing the source file - * @throws ModelScopeError for network or file system errors - */ + /// Downloads a specific chunk with range requests and resume support + /// + /// Performs HTTP range request to download a specific portion of a file, + /// with automatic resume from existing partial downloads and exponential + /// backoff retry logic. + /// + /// - Parameters: + /// - chunk: ChunkInfo containing chunk metadata and temporary file location + /// - file: ModelFile object representing the source file + /// - Throws: ModelScopeError for network or file system errors private func downloadChunk(chunk: ChunkInfo, file: ModelFile) async throws { if chunk.isCompleted { @@ -498,16 +483,14 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { )) } - /** - * Downloads a file directly without chunking - * - * Performs a complete file download in a single request with resume - * capability and progress tracking for smaller files. Uses exponential - * backoff retry mechanism for failed attempts. - * - * @param task DownloadTask containing file information and destination - * @throws ModelScopeError for network or file system errors - */ + /// Downloads a file directly without chunking + /// + /// Performs a complete file download in a single request with resume + /// capability and progress tracking for smaller files. Uses exponential + /// backoff retry mechanism for failed attempts. + /// + /// - Parameter task: DownloadTask containing file information and destination + /// - Throws: ModelScopeError for network or file system errors private func downloadFileDirect(task: DownloadTask) async throws { ModelDownloadLogger.info("downloadFileDirect \(task.file.name)") @@ -607,15 +590,13 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { )) } - /** - * Merges downloaded chunks into the final file - * - * Combines all downloaded chunks in the correct order to create the - * final file, then cleans up temporary chunk files. - * - * @param task DownloadTask containing chunk information and destination - * @throws ModelScopeError for file system errors during merging - */ + /// Merges downloaded chunks into the final file + /// + /// Combines all downloaded chunks in the correct order to create the + /// final file, then cleans up temporary chunk files. + /// + /// - Parameter task: DownloadTask containing chunk information and destination + /// - Throws: ModelScopeError for file system errors during merging private func mergeChunks(task: DownloadTask) async throws { let destination = URL(fileURLWithPath: task.destinationPath) .appendingPathComponent(task.file.name.sanitizedPath) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift index 11073426..1fd31dea 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift @@ -7,45 +7,42 @@ import Foundation -/** - * Protocol defining the interface for model download managers - * - * This protocol enables dependency injection and provides a common contract for different download implementations. - * It supports concurrent downloads, progress tracking, and cancellation functionality. - * - * Key Features: - * - Asynchronous download operations with progress callbacks - * - Cancellation support with resume capability - * - Actor-based thread safety - * - Flexible destination path configuration - * - * Usage: - * ```swift - * let manager: ModelDownloadManagerProtocol = ModelDownloadManager(...) - * try await manager.downloadModel( - * to: "models", - * modelId: "example-model", - * modelName: "ExampleModel" - * ) { progress in - * print("Progress: \(progress * 100)%") - * } - * ``` - */ +/// Protocol defining the interface for model download managers +/// +/// This protocol enables dependency injection and provides a common contract for different download implementations. +/// It supports concurrent downloads, progress tracking, and cancellation functionality. +/// +/// Key Features: +/// - Asynchronous download operations with progress callbacks +/// - Cancellation support with resume capability +/// - Actor-based thread safety +/// - Flexible destination path configuration +/// +/// Usage: +/// ```swift +/// let manager: ModelDownloadManagerProtocol = ModelDownloadManager(...) +/// try await manager.downloadModel( +/// to: "models", +/// modelId: "example-model", +/// modelName: "ExampleModel" +/// ) { progress in +/// print("Progress: \(progress * 100)%") +/// } +/// ``` @available(iOS 13.4, macOS 10.15, *) public protocol ModelDownloadManagerProtocol: Actor, Sendable { - /** - * Downloads a model from the repository with progress tracking - * - * This method initiates an asynchronous download operation for the specified model. - * It supports resume functionality and provides real-time progress updates. - * - * @param destinationFolder Base folder where the model will be downloaded - * @param modelId Unique identifier for the model in the repository - * @param modelName Display name of the model (used for folder creation) - * @param progress Optional closure called with download progress (0.0 to 1.0) - * @throws ModelScopeError for network, file system, or validation failures - */ + /// Downloads a model from the repository with progress tracking + /// + /// This method initiates an asynchronous download operation for the specified model. + /// It supports resume functionality and provides real-time progress updates. + /// + /// - Parameters: + /// - destinationFolder: Base folder where the model will be downloaded + /// - modelId: Unique identifier for the model in the repository + /// - modelName: Display name of the model (used for folder creation) + /// - progress: Optional closure called with download progress (0.0 to 1.0) + /// - Throws: ModelScopeError for network, file system, or validation failures func downloadModel( to destinationFolder: String, modelId: String, @@ -53,45 +50,39 @@ public protocol ModelDownloadManagerProtocol: Actor, Sendable { progress: ((Double) -> Void)? ) async throws - /** - * Cancels the current download operation - * - * This method gracefully stops the download process while preserving temporary files - * to support resume functionality in future download attempts. - */ + /// Cancels the current download operation + /// + /// This method gracefully stops the download process while preserving temporary files + /// to support resume functionality in future download attempts. func cancelDownload() async } -/** - * Type-erased wrapper for ModelDownloadManagerProtocol - * - * This class provides a concrete type that can be stored as a property while maintaining protocol flexibility. - * It wraps any ModelDownloadManagerProtocol implementation and forwards method calls to the underlying manager. - * - * Key Benefits: - * - Enables storing protocol instances as properties - * - Maintains type safety while providing flexibility - * - Supports dependency injection patterns - * - Preserves all protocol functionality - * - * Usage: - * ```swift - * let concreteManager = ModelDownloadManager(...) - * let anyManager = AnyModelDownloadManager(concreteManager) - * // Can now store anyManager as a property - * ``` - */ +/// Type-erased wrapper for ModelDownloadManagerProtocol +/// +/// This class provides a concrete type that can be stored as a property while maintaining protocol flexibility. +/// It wraps any ModelDownloadManagerProtocol implementation and forwards method calls to the underlying manager. +/// +/// Key Benefits: +/// - Enables storing protocol instances as properties +/// - Maintains type safety while providing flexibility +/// - Supports dependency injection patterns +/// - Preserves all protocol functionality +/// +/// Usage: +/// ```swift +/// let concreteManager = ModelDownloadManager(...) +/// let anyManager = AnyModelDownloadManager(concreteManager) +/// // Can now store anyManager as a property +/// ``` @available(iOS 13.4, macOS 10.15, *) public actor AnyModelDownloadManager: ModelDownloadManagerProtocol { private let _downloadModel: (String, String, String, ((Double) -> Void)?) async throws -> Void private let _cancelDownload: () async -> Void - /** - * Creates a type-erased wrapper around any ModelDownloadManagerProtocol implementation - * - * @param manager The concrete download manager to wrap - */ + /// Creates a type-erased wrapper around any ModelDownloadManagerProtocol implementation + /// + /// - Parameter manager: The concrete download manager to wrap public init(_ manager: T) { self._downloadModel = { destinationFolder, modelId, modelName, progress in try await manager.downloadModel( @@ -120,50 +111,45 @@ public actor AnyModelDownloadManager: ModelDownloadManagerProtocol { } } -/** - * Factory protocol for creating download managers - * - * This protocol enables different creation strategies while maintaining type safety. - * It provides a standardized way to create download managers with various configurations. - * - * Key Features: - * - Supports multiple download manager implementations - * - Enables dependency injection and testing - * - Provides consistent creation interface - * - Supports different model sources - * - * Usage: - * ```swift - * let factory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory() - * let manager = factory.createDownloadManager( - * repoPath: "owner/model-name", - * source: .modelScope - * ) - * ``` - */ +/// Factory protocol for creating download managers +/// +/// This protocol enables different creation strategies while maintaining type safety. +/// It provides a standardized way to create download managers with various configurations. +/// +/// Key Features: +/// - Supports multiple download manager implementations +/// - Enables dependency injection and testing +/// - Provides consistent creation interface +/// - Supports different model sources +/// +/// Usage: +/// ```swift +/// let factory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory() +/// let manager = factory.createDownloadManager( +/// repoPath: "owner/model-name", +/// source: .modelScope +/// ) +/// ``` @available(iOS 13.4, macOS 10.15, *) public protocol ModelDownloadManagerFactory { - /** - * Creates a download manager for the specified repository and source - * - * @param repoPath Repository path in format "owner/model-name" - * @param source The model source (ModelScope or Modeler) - * @return A download manager conforming to ModelDownloadManagerProtocol - */ + /// Creates a download manager for the specified repository and source + /// + /// - Parameters: + /// - repoPath: Repository path in format "owner/model-name" + /// - source: The model source (ModelScope or Modeler) + /// - Returns: A download manager conforming to ModelDownloadManagerProtocol func createDownloadManager( repoPath: String, source: ModelSource ) -> any ModelDownloadManagerProtocol } -/** - * Default factory implementation that creates OptimizedModelScopeDownloadManager instances - * - * This factory provides the standard implementation for creating download managers with - * advanced features including dynamic concurrency control, optimized performance settings, - * and comprehensive configuration options. - */ +/// Default factory implementation that creates OptimizedModelScopeDownloadManager instances +/// +/// This factory provides the standard implementation for creating download managers with +/// advanced features including dynamic concurrency control, optimized performance settings, +/// and comprehensive configuration options. @available(iOS 13.4, macOS 10.15, *) public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { @@ -172,14 +158,13 @@ public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { private let enableLogging: Bool private let concurrencyConfig: DynamicConcurrencyConfig - /** - * Creates a factory with specified configuration - * - * @param config Download configuration settings - * @param sessionConfig URLSession configuration - * @param enableLogging Whether to enable debug logging - * @param concurrencyConfig Dynamic concurrency configuration - */ + /// Creates a factory with specified configuration + /// + /// - Parameters: + /// - config: Download configuration settings + /// - sessionConfig: URLSession configuration + /// - enableLogging: Whether to enable debug logging + /// - concurrencyConfig: Dynamic concurrency configuration public init( config: DownloadConfig = .default, sessionConfig: URLSessionConfiguration = .default, @@ -207,24 +192,21 @@ public struct DefaultModelDownloadManagerFactory: ModelDownloadManagerFactory { } } -/** - * Legacy factory implementation that creates ModelScopeDownloadManager instances - * - * This factory is provided for backward compatibility and testing purposes. - * It creates the original ModelScopeDownloadManager without advanced optimizations. - */ +/// Legacy factory implementation that creates ModelScopeDownloadManager instances +/// +/// This factory is provided for backward compatibility and testing purposes. +/// It creates the original ModelScopeDownloadManager without advanced optimizations. @available(iOS 13.4, macOS 10.15, *) public struct LegacyModelDownloadManagerFactory: ModelDownloadManagerFactory { private let sessionConfig: URLSessionConfiguration private let enableLogging: Bool - /** - * Creates a legacy factory with specified configuration - * - * @param sessionConfig URLSession configuration - * @param enableLogging Whether to enable debug logging - */ + /// Creates a legacy factory with specified configuration + /// + /// - Parameters: + /// - sessionConfig: URLSession configuration + /// - enableLogging: Whether to enable debug logging public init( sessionConfig: URLSessionConfiguration = .default, enableLogging: Bool = true diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift index da17159b..9bf7b6dc 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift @@ -7,44 +7,42 @@ import Foundation -/** - * ModelDownloadStorage - Persistent storage manager for download state tracking - * - * This class provides comprehensive storage management for tracking downloaded model files, - * their metadata, and download completion status. It uses UserDefaults for persistence - * and maintains file integrity through size and revision validation. - * - * Key Features: - * - Persistent download state tracking across app sessions - * - File integrity validation using size and revision checks - * - Efficient storage using relative path mapping - * - Thread-safe operations with immediate persistence - * - Automatic cleanup and state management - * - * Architecture: - * - Uses UserDefaults as the underlying storage mechanism - * - Maintains a dictionary mapping relative paths to FileStatus objects - * - Provides atomic operations for state updates - * - Supports both individual file and batch operations - * - * Storage Format: - * - Files are indexed by relative paths from Documents directory - * - Each entry contains size, revision, and timestamp information - * - JSON encoding ensures cross-session compatibility - * - * Usage: - * ```swift - * let storage = ModelDownloadStorage() - * - * // Check if file is already downloaded - * if storage.isFileDownloaded(file, at: destinationPath) { - * print("File already exists and is up to date") - * } - * - * // Save download completion status - * storage.saveFileStatus(file, at: destinationPath) - * ``` - */ +/// ModelDownloadStorage - Persistent storage manager for download state tracking +/// +/// This class provides comprehensive storage management for tracking downloaded model files, +/// their metadata, and download completion status. It uses UserDefaults for persistence +/// and maintains file integrity through size and revision validation. +/// +/// Key Features: +/// - Persistent download state tracking across app sessions +/// - File integrity validation using size and revision checks +/// - Efficient storage using relative path mapping +/// - Thread-safe operations with immediate persistence +/// - Automatic cleanup and state management +/// +/// Architecture: +/// - Uses UserDefaults as the underlying storage mechanism +/// - Maintains a dictionary mapping relative paths to FileStatus objects +/// - Provides atomic operations for state updates +/// - Supports both individual file and batch operations +/// +/// Storage Format: +/// - Files are indexed by relative paths from Documents directory +/// - Each entry contains size, revision, and timestamp information +/// - JSON encoding ensures cross-session compatibility +/// +/// Usage: +/// ```swift +/// let storage = ModelDownloadStorage() +/// +/// // Check if file is already downloaded +/// if storage.isFileDownloaded(file, at: destinationPath) { +/// print("File already exists and is up to date") +/// } +/// +/// // Save download completion status +/// storage.saveFileStatus(file, at: destinationPath) +/// ``` final class ModelDownloadStorage { // MARK: - Properties @@ -53,27 +51,24 @@ final class ModelDownloadStorage { // MARK: - Initialization - /** - * Initializes the storage manager with configurable UserDefaults - * - * @param userDefaults UserDefaults instance for persistence (defaults to .standard) - */ + /// Initializes the storage manager with configurable UserDefaults + /// + /// - Parameter userDefaults: UserDefaults instance for persistence (defaults to .standard) init(userDefaults: UserDefaults = .standard) { self.userDefaults = userDefaults } // MARK: - Public Methods - /** - * Checks if a file has been completely downloaded and is up to date - * - * Validates both file existence on disk and metadata consistency including - * file size and revision to ensure the downloaded file is current and complete. - * - * @param file ModelFile object containing expected metadata - * @param path Destination path where the file should be located - * @return true if file exists and matches expected metadata, false otherwise - */ + /// Checks if a file has been completely downloaded and is up to date + /// + /// Validates both file existence on disk and metadata consistency including + /// file size and revision to ensure the downloaded file is current and complete. + /// + /// - Parameters: + /// - file: ModelFile object containing expected metadata + /// - path: Destination path where the file should be located + /// - Returns: true if file exists and matches expected metadata, false otherwise func isFileDownloaded(_ file: ModelFile, at path: String) -> Bool { let filePath = (path as NSString).appendingPathComponent(file.name.sanitizedPath) let relativePath = getRelativePath(from: filePath) @@ -87,16 +82,15 @@ final class ModelDownloadStorage { return fileStatus.size == file.size && fileStatus.revision == file.revision } - /** - * Saves the download completion status for a file - * - * Records file metadata including size, revision, and download timestamp - * to persistent storage. This enables resume functionality and prevents - * unnecessary re-downloads of unchanged files. - * - * @param file ModelFile object containing file metadata - * @param path Destination path where the file was downloaded - */ + /// Saves the download completion status for a file + /// + /// Records file metadata including size, revision, and download timestamp + /// to persistent storage. This enables resume functionality and prevents + /// unnecessary re-downloads of unchanged files. + /// + /// - Parameters: + /// - file: ModelFile object containing file metadata + /// - path: Destination path where the file was downloaded func saveFileStatus(_ file: ModelFile, at path: String) { let filePath = (path as NSString).appendingPathComponent(file.name.sanitizedPath) let relativePath = getRelativePath(from: filePath) @@ -118,12 +112,10 @@ final class ModelDownloadStorage { } } - /** - * Retrieves all downloaded file statuses from persistent storage - * - * @return Dictionary mapping relative file paths to FileStatus objects, - * or nil if no download history exists - */ + /// Retrieves all downloaded file statuses from persistent storage + /// + /// - Returns: Dictionary mapping relative file paths to FileStatus objects, + /// or nil if no download history exists func getDownloadedFiles() -> [String: FileStatus]? { guard let data = userDefaults.data(forKey: downloadedFilesKey), let downloadedFiles = try? JSONDecoder().decode([String: FileStatus].self, from: data) else { @@ -132,14 +124,12 @@ final class ModelDownloadStorage { return downloadedFiles } - /** - * Removes download status for a specific file - * - * Cleans up storage by removing the file's download record, typically - * used when files are deleted or need to be re-downloaded. - * - * @param path Full path to the file whose status should be cleared - */ + /// Removes download status for a specific file + /// + /// Cleans up storage by removing the file's download record, typically + /// used when files are deleted or need to be re-downloaded. + /// + /// - Parameter path: Full path to the file whose status should be cleared func clearFileStatus(at path: String) { let relativePath = getRelativePath(from: path) var downloadedFiles = getDownloadedFiles() ?? [:] @@ -153,15 +143,13 @@ final class ModelDownloadStorage { // MARK: - Private Methods - /** - * Converts absolute file paths to relative paths from Documents directory - * - * This normalization ensures consistent path handling across different - * app installations and device configurations. - * - * @param fullPath Absolute file path to convert - * @return Relative path from Documents directory, or original path if conversion fails - */ + /// Converts absolute file paths to relative paths from Documents directory + /// + /// This normalization ensures consistent path handling across different + /// app installations and device configurations. + /// + /// - Parameter fullPath: Absolute file path to convert + /// - Returns: Relative path from Documents directory, or original path if conversion fails private func getRelativePath(from fullPath: String) -> String { guard let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.path else { return fullPath diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift index 2a4939ca..f85efd65 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift @@ -9,56 +9,54 @@ import Foundation // MARK: - ModelScopeDownloadManager -/** - * ModelScopeDownloadManager - Specialized download manager for ModelScope and Modeler platforms - * - * This actor-based download manager provides platform-specific optimizations for downloading - * models from ModelScope and Modeler repositories. It implements intelligent resume functionality, - * comprehensive error handling, and maintains directory structure integrity. - * - * Key Features: - * - Platform-specific URL handling for ModelScope and Modeler - * - Intelligent resume capability with temporary file preservation - * - Real-time progress tracking with optimized callback frequency - * - Recursive directory structure preservation - * - File integrity validation using size verification - * - Exponential backoff retry mechanism with configurable attempts - * - Memory-efficient streaming downloads - * - Thread-safe operations using Swift Actor model - * - * Architecture: - * - Uses URLSession.bytes for memory-efficient streaming - * - Implements temporary file management for resume functionality - * - Supports both ModelScope and Modeler API endpoints - * - Maintains download state persistence through ModelDownloadStorage - * - * Performance Optimizations: - * - Progress update throttling (every 320KB) to prevent UI blocking - * - Temporary file reuse for interrupted downloads - * - Efficient directory traversal with recursive file discovery - * - Minimal memory footprint through streaming downloads - * - * Error Handling: - * - Comprehensive retry logic with exponential backoff - * - Graceful cancellation with state preservation - * - File integrity validation and automatic cleanup - * - Network error recovery with configurable retry attempts - * - * Usage: - * ```swift - * let manager = ModelScopeDownloadManager( - * repoPath: "damo/Qwen-1.5B", - * source: .modelScope - * ) - * try await manager.downloadModel( - * to: "models", - * modelId: "qwen-1.5b", - * modelName: "Qwen-1.5B" - * ) { progress in - * print("Progress: \(progress * 100)%") - * } - * ``` - */ +/// ModelScopeDownloadManager - Specialized download manager for ModelScope and Modeler platforms +/// +/// This actor-based download manager provides platform-specific optimizations for downloading +/// models from ModelScope and Modeler repositories. It implements intelligent resume functionality, +/// comprehensive error handling, and maintains directory structure integrity. +/// +/// Key Features: +/// - Platform-specific URL handling for ModelScope and Modeler +/// - Intelligent resume capability with temporary file preservation +/// - Real-time progress tracking with optimized callback frequency +/// - Recursive directory structure preservation +/// - File integrity validation using size verification +/// - Exponential backoff retry mechanism with configurable attempts +/// - Memory-efficient streaming downloads +/// - Thread-safe operations using Swift Actor model +/// +/// Architecture: +/// - Uses URLSession.bytes for memory-efficient streaming +/// - Implements temporary file management for resume functionality +/// - Supports both ModelScope and Modeler API endpoints +/// - Maintains download state persistence through ModelDownloadStorage +/// +/// Performance Optimizations: +/// - Progress update throttling (every 320KB) to prevent UI blocking +/// - Temporary file reuse for interrupted downloads +/// - Efficient directory traversal with recursive file discovery +/// - Minimal memory footprint through streaming downloads +/// +/// Error Handling: +/// - Comprehensive retry logic with exponential backoff +/// - Graceful cancellation with state preservation +/// - File integrity validation and automatic cleanup +/// - Network error recovery with configurable retry attempts +/// +/// Usage: +/// ```swift +/// let manager = ModelScopeDownloadManager( +/// repoPath: "damo/Qwen-1.5B", +/// source: .modelScope +/// ) +/// try await manager.downloadModel( +/// to: "models", +/// modelId: "qwen-1.5b", +/// modelName: "Qwen-1.5B" +/// ) { progress in +/// print("Progress: \(progress * 100)%") +/// } +/// ``` @available(iOS 13.4, macOS 10.15, *) public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Properties @@ -82,16 +80,15 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Initialization - /** - * Creates a new ModelScope download manager with platform-specific configuration - * - * @param repoPath Repository path in format "owner/model-name" - * @param config URLSession configuration for network behavior customization - * Use .default for standard downloads, .background for background downloads - * @param enableLogging Whether to enable detailed debug logging - * @param source Target platform (ModelScope or Modeler) - * @note When using background configuration, the app must handle URLSession background events - */ + /// Creates a new ModelScope download manager with platform-specific configuration + /// + /// - Parameters: + /// - repoPath: Repository path in format "owner/model-name" + /// - config: URLSession configuration for network behavior customization + /// Use .default for standard downloads, .background for background downloads + /// - enableLogging: Whether to enable detailed debug logging + /// - source: Target platform (ModelScope or Modeler) + /// - Note: When using background configuration, the app must handle URLSession background events public init( repoPath: String, config: URLSessionConfiguration = .default, @@ -108,30 +105,29 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Public Methods - /** - * Downloads a complete model from ModelScope or Modeler repository - * - * This method orchestrates the entire download process including file discovery, - * directory structure creation, resume functionality, and progress tracking. - * It supports both ModelScope and Modeler platforms with platform-specific optimizations. - * - * @param destinationFolder Base folder for download (relative to Documents) - * @param modelId Unique identifier for the model - * @param modelName Display name used for folder creation - * @param progress Optional progress callback (0.0 to 1.0) - * @throws ModelScopeError for network, file system, or validation failures - * - * Example: - * ```swift - * try await manager.downloadModel( - * to: "models", - * modelId: "qwen-1.5b", - * modelName: "Qwen-1.5B" - * ) { progress in - * print("Progress: \(progress * 100)%") - * } - * ``` - */ + /// Downloads a complete model from ModelScope or Modeler repository + /// + /// This method orchestrates the entire download process including file discovery, + /// directory structure creation, resume functionality, and progress tracking. + /// It supports both ModelScope and Modeler platforms with platform-specific optimizations. + /// + /// - Parameters: + /// - destinationFolder: Base folder for download (relative to Documents) + /// - modelId: Unique identifier for the model + /// - modelName: Display name used for folder creation + /// - progress: Optional progress callback (0.0 to 1.0) + /// - Throws: ModelScopeError for network, file system, or validation failures + /// + /// Example: + /// ```swift + /// try await manager.downloadModel( + /// to: "models", + /// modelId: "qwen-1.5b", + /// modelName: "Qwen-1.5B" + /// ) { progress in + /// print("Progress: \(progress * 100)%") + /// } + /// ``` public func downloadModel( to destinationFolder: String = "", modelId: String, @@ -158,13 +154,11 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { ) } - /** - * Cancels all ongoing download operations while preserving resume capability - * - * This method gracefully stops all active downloads, closes file handles, - * and preserves temporary files to enable resume functionality in future attempts. - * The URLSession is invalidated to ensure clean cancellation. - */ + /// Cancels all ongoing download operations while preserving resume capability + /// + /// This method gracefully stops all active downloads, closes file handles, + /// and preserves temporary files to enable resume functionality in future attempts. + /// The URLSession is invalidated to ensure clean cancellation. public func cancelDownload() async { isCancelled = true @@ -180,30 +174,28 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Private Methods - Progress Management - /** - * Updates download progress with throttling to prevent excessive UI updates - * - * @param progress Current progress value (0.0 to 1.0) - * @param callback Progress callback function to invoke on main thread - */ + /// Updates download progress with throttling to prevent excessive UI updates + /// + /// - Parameters: + /// - progress: Current progress value (0.0 to 1.0) + /// - callback: Progress callback function to invoke on main thread private func updateProgress(_ progress: Double, callback: @escaping (Double) -> Void) { Task { @MainActor in callback(progress) } } - /** - * Fetches the complete file list from ModelScope or Modeler repository - * - * This method queries the repository API to discover all available files, - * supporting both ModelScope and Modeler platform endpoints with proper - * error handling and response validation. - * - * @param root Root directory path to fetch files from - * @param revision Model revision/version to fetch files for - * @return Array of ModelFile objects representing repository files - * @throws ModelScopeError if request fails or response is invalid - */ + /// Fetches the complete file list from ModelScope or Modeler repository + /// + /// This method queries the repository API to discover all available files, + /// supporting both ModelScope and Modeler platform endpoints with proper + /// error handling and response validation. + /// + /// - Parameters: + /// - root: Root directory path to fetch files from + /// - revision: Model revision/version to fetch files for + /// - Returns: Array of ModelFile objects representing repository files + /// - Throws: ModelScopeError if request fails or response is invalid private func fetchFileList( root: String, revision: String @@ -414,19 +406,18 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } - /** - * Downloads files recursively with directory structure preservation - * - * This method processes the complete file list, creating necessary directory - * structures and downloading files in the correct order. It calculates total - * download size, handles existing files, and maintains progress tracking. - * - * @param files Array of ModelFile objects representing all repository files - * @param revision Model revision/version for download URLs - * @param destinationPath Base directory path for downloads - * @param progress Progress callback function (0.0 to 1.0) - * @throws ModelScopeError if any file download fails - */ + /// Downloads files recursively with directory structure preservation + /// + /// This method processes the complete file list, creating necessary directory + /// structures and downloading files in the correct order. It calculates total + /// download size, handles existing files, and maintains progress tracking. + /// + /// - Parameters: + /// - files: Array of ModelFile objects representing all repository files + /// - revision: Model revision/version for download URLs + /// - destinationPath: Base directory path for downloads + /// - progress: Progress callback function (0.0 to 1.0) + /// - Throws: ModelScopeError if any file download fails private func downloadFiles( files: [ModelFile], revision: String, @@ -506,17 +497,16 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } - /** - * Calculates the total download size for progress tracking - * - * Recursively traverses directory structures to compute the total size - * of all files that need to be downloaded, enabling accurate progress reporting. - * - * @param files Array of ModelFile objects to calculate size for - * @param revision Model revision for fetching subdirectory contents - * @return Total size in bytes across all files - * @throws ModelScopeError if file list fetching fails - */ + /// Calculates the total download size for progress tracking + /// + /// Recursively traverses directory structures to compute the total size + /// of all files that need to be downloaded, enabling accurate progress reporting. + /// + /// - Parameters: + /// - files: Array of ModelFile objects to calculate size for + /// - revision: Model revision for fetching subdirectory contents + /// - Returns: Total size in bytes across all files + /// - Throws: ModelScopeError if file list fetching fails private func calculateTotalSize(files: [ModelFile], revision: String) async throws -> Int64 { var size: Int64 = 0 for file in files { @@ -534,12 +524,10 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } - /** - * Resets internal download state for a fresh download session - * - * Clears progress counters and prepares the manager for a new download operation. - * This method is called at the beginning of each download to ensure clean state. - */ + /// Resets internal download state for a fresh download session + /// + /// Clears progress counters and prepares the manager for a new download operation. + /// This method is called at the beginning of each download to ensure clean state. private func resetDownloadState() async { totalFiles = 0 downloadedFiles = 0 @@ -548,12 +536,10 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { lastUpdatedBytes = 0 } - /** - * Resets the cancellation flag to allow new download operations - * - * Clears all download state including cancellation status and progress counters, - * preparing the manager for a completely fresh download session. - */ + /// Resets the cancellation flag to allow new download operations + /// + /// Clears all download state including cancellation status and progress counters, + /// preparing the manager for a completely fresh download session. private func resetCancelStatus() { isCancelled = false @@ -564,12 +550,10 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { lastUpdatedBytes = 0 } - /** - * Safely closes the current file handle to prevent resource leaks - * - * This method ensures proper cleanup of file handles during cancellation - * or error conditions, preventing file descriptor leaks. - */ + /// Safely closes the current file handle to prevent resource leaks + /// + /// This method ensures proper cleanup of file handles during cancellation + /// or error conditions, preventing file descriptor leaks. private func closeFileHandle() async { do { try currentFileHandle?.close() @@ -579,17 +563,16 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } - /** - * Constructs ModelScope API URLs with proper query parameters - * - * Builds complete URLs for ModelScope repository API endpoints, - * handling URL encoding and validation. - * - * @param path API endpoint path to append to base URL - * @param queryItems URL query parameters for the request - * @return Constructed and validated URL - * @throws ModelScopeError.invalidURL if URL construction fails - */ + /// Constructs ModelScope API URLs with proper query parameters + /// + /// Builds complete URLs for ModelScope repository API endpoints, + /// handling URL encoding and validation. + /// + /// - Parameters: + /// - path: API endpoint path to append to base URL + /// - queryItems: URL query parameters for the request + /// - Returns: Constructed and validated URL + /// - Throws: ModelScopeError.invalidURL if URL construction fails private func buildURL( path: String, queryItems: [URLQueryItem] @@ -606,17 +589,16 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return url } - /** - * Constructs Modeler platform URLs with proper query parameters - * - * Builds complete URLs for Modeler repository API endpoints, - * handling URL encoding and validation for the Modeler platform. - * - * @param path File path within the repository - * @param queryItems URL query parameters for the request - * @return Constructed and validated URL - * @throws ModelScopeError.invalidURL if URL construction fails - */ + /// Constructs Modeler platform URLs with proper query parameters + /// + /// Builds complete URLs for Modeler repository API endpoints, + /// handling URL encoding and validation for the Modeler platform. + /// + /// - Parameters: + /// - path: File path within the repository + /// - queryItems: URL query parameters for the request + /// - Returns: Constructed and validated URL + /// - Throws: ModelScopeError.invalidURL if URL construction fails private func buildModelerURL( path: String, queryItems: [URLQueryItem] @@ -633,15 +615,13 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return url } - /** - * Validates HTTP response status codes for successful requests - * - * Ensures the HTTP response indicates success (2xx status codes) - * and throws appropriate errors for failed requests. - * - * @param response URLResponse to validate - * @throws ModelScopeError.invalidResponse if status code indicates failure - */ + /// Validates HTTP response status codes for successful requests + /// + /// Ensures the HTTP response indicates success (2xx status codes) + /// and throws appropriate errors for failed requests. + /// + /// - Parameter response: URLResponse to validate + /// - Throws: ModelScopeError.invalidResponse if status code indicates failure private func validateResponse(_ response: URLResponse) throws { guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { @@ -649,17 +629,16 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } } - /** - * Resolves and creates the complete destination path for model downloads - * - * Constructs the full local file system path where the model will be downloaded, - * creating necessary directory structures and validating access permissions. - * - * @param base Base folder path relative to Documents directory - * @param modelId Model identifier used for folder naming - * @return Absolute path to the model download directory - * @throws ModelScopeError.fileSystemError if directory creation fails - */ + /// Resolves and creates the complete destination path for model downloads + /// + /// Constructs the full local file system path where the model will be downloaded, + /// creating necessary directory structures and validating access permissions. + /// + /// - Parameters: + /// - base: Base folder path relative to Documents directory + /// - modelId: Model identifier used for folder naming + /// - Returns: Absolute path to the model download directory + /// - Throws: ModelScopeError.fileSystemError if directory creation fails private func resolveDestinationPath( base: String, modelId: String @@ -687,25 +666,22 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return modelScopePath.path } - /** - * Thread-safe setter for the current file handle - * - * @param handle FileHandle instance to set, or nil to clear - */ + /// Thread-safe setter for the current file handle + /// + /// - Parameter handle: FileHandle instance to set, or nil to clear private func setCurrentFileHandle(_ handle: FileHandle?) { currentFileHandle = handle } - /** - * Retrieves the size of a temporary file for resume functionality - * - * Calculates the current size of a temporary download file to determine - * the resume offset for interrupted downloads. - * - * @param file ModelFile to get temporary file size for - * @param destinationPath Destination path used for temp file naming - * @return Size of temporary file in bytes, or 0 if file doesn't exist - */ + /// Retrieves the size of a temporary file for resume functionality + /// + /// Calculates the current size of a temporary download file to determine + /// the resume offset for interrupted downloads. + /// + /// - Parameters: + /// - file: ModelFile to get temporary file size for + /// - destinationPath: Destination path used for temp file naming + /// - Returns: Size of temporary file in bytes, or 0 if file doesn't exist private func getTempFileSize(for file: ModelFile, destinationPath: String) -> Int64 { let modelHash = repoPath.hash let fileHash = file.path.hash diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift index ca7da473..2a1684cb 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift @@ -20,16 +20,13 @@ struct ActionButtonsView: View { var body: some View { VStack(alignment: .center, spacing: 4) { if model.isDownloaded { - // 已下载状态 DownloadedButtonView(showDeleteAlert: $showDeleteAlert) } else if isDownloading { - // 下载中状态 DownloadingButtonView( viewModel: viewModel, downloadProgress: downloadProgress ) } else { - // 待下载状态 PendingDownloadButtonView( isOtherDownloading: isOtherDownloading, formattedSize: formattedSize, diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/FileOperationManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/FileOperationManager.swift index 8d9e9f17..04da3d65 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/FileOperationManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/FileOperationManager.swift @@ -7,10 +7,8 @@ import Foundation import UIKit -/** - * FileOperationManager is a singleton utility class that handles various file operations - * including image processing, audio processing, directory size calculation, and file cleanup. - */ +/// FileOperationManager is a singleton utility class that handles various file operations +/// including image processing, audio processing, directory size calculation, and file cleanup. final class FileOperationManager { /// Shared singleton instance @@ -21,22 +19,20 @@ final class FileOperationManager { // MARK: - Image Processing - /** - * Processes image files by copying to temporary directory and performing HEIC conversion if needed - * - * - Parameters: - * - url: The original image URL - * - fileName: The desired file name for the processed image - * - Returns: The processed image URL, or nil if processing fails - * - * Usage: - * ```swift - * let imageURL = URL(fileURLWithPath: "/path/to/image.heic") - * if let processedURL = FileOperationManager.shared.processImageFile(from: imageURL, fileName: "converted.jpg") { - * // Use the processed image URL - * } - * ``` - */ + /// Processes image files by copying to temporary directory and performing HEIC conversion if needed + /// + /// - Parameters: + /// - url: The original image URL + /// - fileName: The desired file name for the processed image + /// - Returns: The processed image URL, or nil if processing fails + /// + /// Usage: + /// ```swift + /// let imageURL = URL(fileURLWithPath: "/path/to/image.heic") + /// if let processedURL = FileOperationManager.shared.processImageFile(from: imageURL, fileName: "converted.jpg") { + /// // Use the processed image URL + /// } + /// ``` func processImageFile(from url: URL, fileName: String) -> URL? { let isInTempDirectory = url.path.contains("/tmp/") @@ -50,12 +46,10 @@ final class FileOperationManager { } } - /** - * Converts HEIC images to JPG format using AssetExtractor utility - * - * - Parameter url: The HEIC image URL to convert - * - Returns: The converted JPG image URL, or original URL if not HEIC format - */ + /// Converts HEIC images to JPG format using AssetExtractor utility + /// + /// - Parameter url: The HEIC image URL to convert + /// - Returns: The converted JPG image URL, or original URL if not HEIC format private func convertHEICImage(from url: URL) -> URL? { var fileUrl = url if fileUrl.isHEICImage() { @@ -69,19 +63,17 @@ final class FileOperationManager { // MARK: - Directory Size Calculation - /** - * Formats byte count into human-readable string using ByteCountFormatter - * - * - Parameter bytes: The number of bytes to format - * - Returns: Formatted string (e.g., "1.5 GB") - * - * Usage: - * ```swift - * let size: Int64 = 1073741824 // 1 GB - * let formatted = FileOperationManager.shared.formatBytes(size) - * print(formatted) // "1.0 GB" - * ``` - */ + /// Formats byte count into human-readable string using ByteCountFormatter + /// + /// - Parameter bytes: The number of bytes to format + /// - Returns: Formatted string (e.g., "1.5 GB") + /// + /// Usage: + /// ```swift + /// let size: Int64 = 1073741824 // 1 GB + /// let formatted = FileOperationManager.shared.formatBytes(size) + /// print(formatted) // "1.0 GB" + /// ``` func formatBytes(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useGB] @@ -89,19 +81,17 @@ final class FileOperationManager { return formatter.string(fromByteCount: bytes) } - /** - * Calculates the size of a local directory and returns a formatted string - * - * - Parameter path: The directory path to calculate size for - * - Returns: Formatted size string or "Unknown" if calculation fails - * - * Usage: - * ```swift - * let directoryPath = "/path/to/directory" - * let sizeString = FileOperationManager.shared.formatLocalDirectorySize(at: directoryPath) - * print("Directory size: \(sizeString)") - * ``` - */ + /// Calculates the size of a local directory and returns a formatted string + /// + /// - Parameter path: The directory path to calculate size for + /// - Returns: Formatted size string or "Unknown" if calculation fails + /// + /// Usage: + /// ```swift + /// let directoryPath = "/path/to/directory" + /// let sizeString = FileOperationManager.shared.formatLocalDirectorySize(at: directoryPath) + /// print("Directory size: \(sizeString)") + /// ``` func formatLocalDirectorySize(at path: String) -> String { guard FileManager.default.fileExists(atPath: path) else { return "Unknown" } @@ -113,24 +103,22 @@ final class FileOperationManager { } } - /** - * Calculates the total size of a directory by traversing all files recursively - * Uses actual disk allocated size when available, falls back to logical file size - * - * - Parameter path: The directory path to calculate size for - * - Returns: Total directory size in bytes - * - Throws: FileSystem errors during directory traversal - * - * Usage: - * ```swift - * do { - * let size = try FileOperationManager.shared.calculateDirectorySize(at: "/path/to/directory") - * print("Directory size: \(size) bytes") - * } catch { - * print("Failed to calculate directory size: \(error)") - * } - * ``` - */ + /// Calculates the total size of a directory by traversing all files recursively + /// Uses actual disk allocated size when available, falls back to logical file size + /// + /// - Parameter path: The directory path to calculate size for + /// - Returns: Total directory size in bytes + /// - Throws: FileSystem errors during directory traversal + /// + /// Usage: + /// ```swift + /// do { + /// let size = try FileOperationManager.shared.calculateDirectorySize(at: "/path/to/directory") + /// print("Directory size: \(size) bytes") + /// } catch { + /// print("Failed to calculate directory size: \(error)") + /// } + /// ``` func calculateDirectorySize(at path: String) throws -> Int64 { let fileManager = FileManager.default var totalSize: Int64 = 0 @@ -201,17 +189,15 @@ final class FileOperationManager { // MARK: - Directory Cleaning - /** - * Cleans temporary directories based on memory mapping usage - * Cleans system temporary directory and optionally model temporary directories - * - * - * Usage: - * ```swift - * // Clean temporary directories - * FileOperationManager.shared.cleanTempDirectories() - * ``` - */ + /// Cleans temporary directories based on memory mapping usage + /// Cleans system temporary directory and optionally model temporary directories + /// + /// + /// Usage: + /// ```swift + /// // Clean temporary directories + /// FileOperationManager.shared.cleanTempDirectories() + /// ``` func cleanTempDirectories() { let fileManager = FileManager.default let tmpDirectoryURL = fileManager.temporaryDirectory @@ -219,28 +205,24 @@ final class FileOperationManager { cleanFolder(at: tmpDirectoryURL) } - /** - * Cleans the temporary folder for a specific model - * - * - Parameter modelPath: The path to the model directory - * - * Usage: - * ```swift - * let modelPath = "/path/to/model" - * FileOperationManager.shared.cleanModelTempFolder(modelPath: modelPath) - * ``` - */ + /// Cleans the temporary folder for a specific model + /// + /// - Parameter modelPath: The path to the model directory + /// + /// Usage: + /// ```swift + /// let modelPath = "/path/to/model" + /// FileOperationManager.shared.cleanModelTempFolder(modelPath: modelPath) + /// ``` func cleanModelTempFolder(modelPath: String) { let tmpFolderURL = URL(fileURLWithPath: modelPath).appendingPathComponent("temp") cleanFolder(at: tmpFolderURL) } - /** - * Recursively cleans all files in the specified folder - * Preserves files containing "networkdownload" in their path - * - * - Parameter folderURL: The folder URL to clean - */ + /// Recursively cleans all files in the specified folder + /// Preserves files containing "networkdownload" in their path + /// + /// - Parameter folderURL: The folder URL to clean private func cleanFolder(at folderURL: URL) { let fileManager = FileManager.default do { @@ -262,18 +244,16 @@ final class FileOperationManager { // MARK: - Diffusion Image Generation - /** - * Generates a unique temporary file path for Diffusion model image output - * Creates a unique JPG filename in the system temporary directory - * - * - Returns: A unique temporary image file URL - * - * Usage: - * ```swift - * let tempImageURL = FileOperationManager.shared.generateTempImagePath() - * // Use tempImageURL for image generation output - * ``` - */ + /// Generates a unique temporary file path for Diffusion model image output + /// Creates a unique JPG filename in the system temporary directory + /// + /// - Returns: A unique temporary image file URL + /// + /// Usage: + /// ```swift + /// let tempImageURL = FileOperationManager.shared.generateTempImagePath() + /// // Use tempImageURL for image generation output + /// ``` func generateTempImagePath() -> URL { let tempDir = FileManager.default.temporaryDirectory let imageName = UUID().uuidString + ".jpg" From d061d779004b700ffafea033d8070637348ce280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 15:52:14 +0800 Subject: [PATCH 06/25] [fix] disable trash action for local model --- .../Views/ModelRowSubviews/ActionButtonsView.swift | 4 +++- .../MainTab/ModelList/Views/SwipeActionsView.swift | 8 +------- .../MNNLLMiOS/{ => Service}/Util/ModelUtils.swift | 9 +++++++++ .../MNNLLMiOS/Settings/View/SettingsView.swift | 2 -- 4 files changed, 13 insertions(+), 10 deletions(-) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/ModelUtils.swift (84%) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift index 2a1684cb..3186b2c2 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelRowSubviews/ActionButtonsView.swift @@ -20,7 +20,9 @@ struct ActionButtonsView: View { var body: some View { VStack(alignment: .center, spacing: 4) { if model.isDownloaded { - DownloadedButtonView(showDeleteAlert: $showDeleteAlert) + if !ModelUtils.isBuiltInLocalModel(model) { + DownloadedButtonView(showDeleteAlert: $showDeleteAlert) + } } else if isDownloading { DownloadingButtonView( viewModel: viewModel, diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/SwipeActionsView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/SwipeActionsView.swift index ed2006f0..94c95e73 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/SwipeActionsView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/SwipeActionsView.swift @@ -12,12 +12,6 @@ struct SwipeActionsView: View { let model: ModelInfo @ObservedObject var viewModel: ModelListViewModel - private func isBuiltInLocalModel(_ model: ModelInfo) -> Bool { - guard let vendor = model.vendor, vendor == "Local" else { return false } - guard let sources = model.sources, let localSource = sources["local"] else { return false } - return localSource.hasPrefix("bundle_root/") - } - var body: some View { if viewModel.pinnedModelIds.contains(model.id) { Button { @@ -32,7 +26,7 @@ struct SwipeActionsView: View { Label(LocalizedStringKey("button.pin"), systemImage: "pin") }.tint(.primaryBlue) } - if model.isDownloaded && !isBuiltInLocalModel(model) { + if model.isDownloaded && !ModelUtils.isBuiltInLocalModel(model) { Button(role: .destructive) { Task { await viewModel.deleteModel(model) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/ModelUtils.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/ModelUtils.swift similarity index 84% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/ModelUtils.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/ModelUtils.swift index b473b2a9..429b919e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/ModelUtils.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/ModelUtils.swift @@ -18,6 +18,15 @@ class ModelUtils { tags.contains(where: { $0.localizedCaseInsensitiveContains("思考") })) } + /// Check if the model is built in local model + /// - Parameter model: ModelInfo + /// - Returns: Whether is built in local model + static func isBuiltInLocalModel(_ model: ModelInfo) -> Bool { + guard let vendor = model.vendor, vendor == "Local" else { return false } + guard let sources = model.sources, let localSource = sources["local"] else { return false } + return localSource.hasPrefix("bundle_root/") + } + /// Check if it's an R1 model /// - Parameter modelName: Model name /// - Returns: Whether it's an R1 model diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Settings/View/SettingsView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Settings/View/SettingsView.swift index 31e31c06..f72199db 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Settings/View/SettingsView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Settings/View/SettingsView.swift @@ -79,11 +79,9 @@ struct SettingsView: View { .alert("settings.alert.switchLanguage.title", isPresented: $showLanguageAlert) { Button("settings.alert.switchLanguage.confirm") { LanguageManager.shared.applyLanguage(selectedLanguage) - // 重启应用以应用语言更改 exit(0) } Button("settings.alert.switchLanguage.cancel", role: .cancel) { - // 恢复原来的选择 selectedLanguage = LanguageManager.shared.currentLanguage } } message: { From 208153f0629711062861aa029dfb234b4be46392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 16:20:05 +0800 Subject: [PATCH 07/25] [refactor] Main Tab --- .../MNNLLMiOS/MainTab/MainTabView.swift | 231 ++++++++---------- 1 file changed, 103 insertions(+), 128 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift index cd897ac7..6036131a 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift @@ -1,6 +1,6 @@ // -// MNNLLMiOSApp.swift -// MainTabView +// MainTabView.swift +// MNNLLMiOS // // Created by 游薪渝(揽清) on 2025/06/20. // @@ -8,18 +8,22 @@ import SwiftUI struct MainTabView: View { - // MARK: - State Properties + @State private var histories: [ChatHistory] = [] @State private var showHistory = false - @State private var selectedHistory: ChatHistory? = nil - @State private var histories: [ChatHistory] = ChatHistoryManager.shared.getAllHistory() @State private var showHistoryButton = true + @State private var selectedHistory: ChatHistory? = nil + @State private var showSettings = false - @State private var showWebView = false - @State private var webViewURL: URL? + @State private var navigateToSettings = false - @StateObject private var modelListViewModel = ModelListViewModel() + @State private var navigateToChat = false + @State private var selectedTab: Int = 0 + @State private var hasConfiguredAppearance = false + + @StateObject private var modelListViewModel = ModelListViewModel() + private var titles: [String] { [ @@ -29,97 +33,34 @@ struct MainTabView: View { ] } - // MARK: - Body - var body: some View { ZStack { - // Main TabView for navigation between Local Model, Model Market, and Benchmark + TabView(selection: $selectedTab) { - NavigationView { - LocalModelListView(viewModel: modelListViewModel) - .navigationTitle(titles[0]) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(false) - .onAppear { - setupNavigationBarAppearance() - } - .toolbar { - CommonToolbarView( - showHistory: $showHistory, - showHistoryButton: $showHistoryButton, - ) - } - .background( - ZStack { - NavigationLink(destination: chatDestination, isActive: chatIsActiveBinding) { EmptyView() } - NavigationLink(destination: SettingsView(), isActive: $navigateToSettings) { EmptyView() } - } - ) - // Hide TabBar when entering chat or settings view - .toolbar((chatIsActiveBinding.wrappedValue || navigateToSettings) ? .hidden : .visible, for: .tabBar) - } - .tabItem { - Image(systemName: "house.fill") - Text(titles[0]) - } - .tag(0) + createTabContent( + content: LocalModelListView(viewModel: modelListViewModel), + title: titles[0], + icon: "house.fill", + tag: 0 + ) - NavigationView { - ModelListView(viewModel: modelListViewModel) - .navigationTitle(titles[1]) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(false) - .onAppear { - setupNavigationBarAppearance() - } - .toolbar { - CommonToolbarView( - showHistory: $showHistory, - showHistoryButton: $showHistoryButton, - ) - } - .background( - ZStack { - NavigationLink(destination: chatDestination, isActive: chatIsActiveBinding) { EmptyView() } - NavigationLink(destination: SettingsView(), isActive: $navigateToSettings) { EmptyView() } - } - ) - } - .tabItem { - Image(systemName: "doc.text.fill") - Text(titles[1]) - } - .tag(1) + createTabContent( + content: ModelListView(viewModel: modelListViewModel), + title: titles[1], + icon: "doc.text.fill", + tag: 1 + ) - NavigationView { - BenchmarkView() - .navigationTitle(titles[2]) - .navigationBarTitleDisplayMode(.inline) - .navigationBarHidden(false) - .onAppear { - setupNavigationBarAppearance() - } - .toolbar { - CommonToolbarView( - showHistory: $showHistory, - showHistoryButton: $showHistoryButton, - ) - } - .background( - ZStack { - NavigationLink(destination: chatDestination, isActive: chatIsActiveBinding) { EmptyView() } - NavigationLink(destination: SettingsView(), isActive: $navigateToSettings) { EmptyView() } - } - ) - } - .tabItem { - Image(systemName: "clock.fill") - Text(titles[2]) - } - .tag(2) + createTabContent( + content: BenchmarkView(), + title: titles[2], + icon: "clock.fill", + tag: 2 + ) } .onAppear { - setupTabBarAppearance() + setupAppearanceOnce() + loadHistoriesIfNeeded() } .tint(.black) @@ -142,20 +83,16 @@ struct MainTabView: View { .edgesIgnoringSafeArea(.all) } .onChange(of: showHistory) { oldValue, newValue in - if newValue { - // Refresh chat history when opening the side menu - histories = ChatHistoryManager.shared.getAllHistory() - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation { - showHistoryButton = true - } - } + handleHistoryToggle(newValue) + } + .onChange(of: modelListViewModel.selectedModel) { oldValue, newValue in + if newValue != nil { + navigateToChat = true } } - .sheet(isPresented: $showWebView) { - if let url = webViewURL { - WebView(url: url) + .onChange(of: selectedHistory) { oldValue, newValue in + if newValue != nil { + navigateToChat = true } } } @@ -178,34 +115,72 @@ struct MainTabView: View { EmptyView() } } + + // MARK: - Private Methods - // MARK: - Bindings - - /// Binding to control the activation of the chat view. - private var chatIsActiveBinding: Binding { - Binding( - get: { - return modelListViewModel.selectedModel != nil || selectedHistory != nil - }, - set: { isActive in - if !isActive { - // Record usage when returning from chat - if let model = modelListViewModel.selectedModel { - modelListViewModel.recordModelUsage(modelName: model.modelName) - } - - // Refresh chat history when returning from chat - histories = ChatHistoryManager.shared.getAllHistory() - - // Clear selections - modelListViewModel.selectedModel = nil - selectedHistory = nil + /// Creates a reusable tab content with navigation and common configurations. + @ViewBuilder + private func createTabContent( + content: Content, + title: String, + icon: String, + tag: Int + ) -> some View { + NavigationStack { + content + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(false) + .toolbar { + CommonToolbarView( + showHistory: $showHistory, + showHistoryButton: $showHistoryButton + ) } - } - ) + .navigationDestination(isPresented: $navigateToChat) { + chatDestination + } + .navigationDestination(isPresented: $navigateToSettings) { + SettingsView() + } + .toolbar((navigateToChat || navigateToSettings) ? .hidden : .visible, for: .tabBar) + } + .tabItem { + Image(systemName: icon) + Text(title) + } + .tag(tag) } - // MARK: - Private Methods + /// Configures UI appearance only once to prevent memory issues. + private func setupAppearanceOnce() { + guard !hasConfiguredAppearance else { return } + hasConfiguredAppearance = true + + setupNavigationBarAppearance() + setupTabBarAppearance() + } + + /// Loads chat histories if not already loaded. + private func loadHistoriesIfNeeded() { + if histories.isEmpty { + histories = ChatHistoryManager.shared.getAllHistory() + } + } + + /// Handles history toggle with proper memory management. + private func handleHistoryToggle(_ isShowing: Bool) { + if isShowing { + histories = ChatHistoryManager.shared.getAllHistory() + } else { + Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds + withAnimation { + self.showHistoryButton = true + } + } + } + } /// Configures the appearance of the navigation bar. private func setupNavigationBarAppearance() { From 718b73e72c3911c74efaecab2ebdf5874179ed72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 16:52:42 +0800 Subject: [PATCH 08/25] [feat] save model info for histroy --- .../Chat/ViewModels/LLMChatViewModel.swift | 3 +- .../ChatHistory/Models/ChatHistory.swift | 12 +++- .../Services/ChatHistoryDatabase.swift | 58 +++++++++++++++++-- .../Services/ChatHistoryManager.swift | 13 +++-- .../MNNLLMiOS/MainTab/MainTabView.swift | 17 +++++- 5 files changed, 86 insertions(+), 17 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift index 38ecc05e..aa4b6607 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift @@ -395,8 +395,7 @@ final class LLMChatViewModel: ObservableObject { func onStop() { ChatHistoryManager.shared.saveChat( historyId: historyId, - modelId: modelInfo.id, - modelName: modelInfo.modelName, + modelInfo: modelInfo, messages: messages ) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Models/ChatHistory.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Models/ChatHistory.swift index b014efc5..c94e1d2d 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Models/ChatHistory.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Models/ChatHistory.swift @@ -10,11 +10,19 @@ import ExyteChat struct ChatHistory: Codable, Identifiable, Hashable { let id: String - let modelId: String - let modelName: String + let modelInfo: ModelInfo var messages: [HistoryMessage] let createdAt: Date var updatedAt: Date + + // For backward compatibility, provide convenient properties + var modelId: String { + return modelInfo.id + } + + var modelName: String { + return modelInfo.modelName + } } struct HistoryMessage: Codable, Hashable { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift index 45dd2a67..be44b3d2 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift @@ -26,6 +26,7 @@ class ChatHistoryDatabase { private let id: Column private let modelId: Column private let modelName: Column + private let modelInfo: Column // JSON string of ModelInfo private let messages: Column private let createdAt: Column private let updatedAt: Column @@ -38,6 +39,7 @@ class ChatHistoryDatabase { id = Column("id") modelId = Column("modelId") modelName = Column("modelName") + modelInfo = Column("modelInfo") messages = Column("messages") createdAt = Column("createdAt") updatedAt = Column("updatedAt") @@ -46,13 +48,27 @@ class ChatHistoryDatabase { t.column(id, primaryKey: true) t.column(modelId) t.column(modelName) + t.column(modelInfo) t.column(messages) t.column(createdAt) t.column(updatedAt) }) + + // for old data + try migrateDatabase() } - func saveChat(historyId: String, modelId: String, modelName: String, messages: [Message]) { + private func migrateDatabase() throws { + do { + let _ = try db.scalar("SELECT modelInfo FROM chatHistories LIMIT 1") + } catch { + try db.run("ALTER TABLE chatHistories ADD COLUMN modelInfo TEXT") + } + } + + func saveChat(historyId: String, modelInfo: ModelInfo, messages: [Message]) { + let modelId = modelInfo.id + let modelName = modelInfo.modelName do { ChatHistoryFileManager.shared.createHistoryDirectory(for: historyId) @@ -83,7 +99,6 @@ class ChatHistoryDatabase { print("HEIC converted to JPG: \(imageUrl.path)") } - // 验证最终文件是否存在 if ChatHistoryFileManager.shared.validateFileExists(at: imageUrl) { copiedImages.append(LLMChatImage.init(id: msg.id, thumbnail: imageUrl, full: imageUrl)) print("Image successfully saved for history: \(imageUrl.path)") @@ -112,9 +127,13 @@ class ChatHistoryDatabase { let messagesData = try JSONEncoder().encode(historyMessages) let messagesString = String(data: messagesData, encoding: .utf8)! - if let existingHistory = try? db.pluck(chatHistories.filter(id == historyId)) { + let modelInfoData = try JSONEncoder().encode(modelInfo) + let modelInfoString = String(data: modelInfoData, encoding: .utf8)! + + if let _ = try? db.pluck(chatHistories.filter(id == historyId)) { try db.run(chatHistories.filter(id == historyId).update( self.messages <- messagesString, + self.modelInfo <- modelInfoString, updatedAt <- Date() )) } else { @@ -122,6 +141,7 @@ class ChatHistoryDatabase { self.id <- historyId, self.modelId <- modelId, self.modelName <- modelName, + self.modelInfo <- modelInfoString, self.messages <- messagesString, self.createdAt <- Date(), self.updatedAt <- Date() @@ -132,6 +152,12 @@ class ChatHistoryDatabase { } } + // For backward compatibility + func saveChat(historyId: String, modelId: String, modelName: String, messages: [Message]) { + let modelInfo = ModelInfo(modelId: modelId, isDownloaded: true) + saveChat(historyId: historyId, modelInfo: modelInfo, messages: messages) + } + func getAllHistory() -> [ChatHistory] { var histories: [ChatHistory] = [] @@ -140,13 +166,33 @@ class ChatHistoryDatabase { let messagesData = history[messages].data(using: .utf8)! var historyMessages = try JSONDecoder().decode([HistoryMessage].self, from: messagesData) - // 验证并修复图片路径 historyMessages = validateAndFixImagePaths(historyMessages, historyId: history[id]) + var modelInfoObj: ModelInfo + do { + if let modelInfoString = try? history.get(modelInfo), !modelInfoString.isEmpty { + do { + let modelInfoData = modelInfoString.data(using: .utf8)! + modelInfoObj = try JSONDecoder().decode(ModelInfo.self, from: modelInfoData) + print("Successfully decoded ModelInfo from JSON for history: \(history[id])") + } catch { + print("Failed to decode ModelInfo from JSON, using fallback: \(error)") + modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true) + } + } else { + // For backward compatibility + print("No modelInfo data found, using fallback for history: \(history[id])") + modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true) + } + } catch { + // For backward compatibility + print("ModelInfo column not found, using fallback for history: \(history[id])") + modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true) + } + let chatHistory = ChatHistory( id: history[id], - modelId: history[modelId], - modelName: history[modelName], + modelInfo: modelInfoObj, messages: historyMessages, createdAt: history[createdAt], updatedAt: history[updatedAt] diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryManager.swift index 0864ebf2..51600167 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryManager.swift @@ -12,15 +12,20 @@ class ChatHistoryManager { private init() {} - func saveChat(historyId: String, modelId: String, modelName: String, messages: [Message]) { + func saveChat(historyId: String, modelInfo: ModelInfo, messages: [Message]) { ChatHistoryDatabase.shared?.saveChat( historyId: historyId, - modelId: modelId, - modelName: modelName, + modelInfo: modelInfo, messages: messages ) } + // For backward compatibility + func saveChat(historyId: String, modelId: String, modelName: String, messages: [Message]) { + let modelInfo = ModelInfo(modelId: modelId, isDownloaded: true) + saveChat(historyId: historyId, modelInfo: modelInfo, messages: messages) + } + func getAllHistory() -> [ChatHistory] { return ChatHistoryDatabase.shared?.getAllHistory() ?? [] } @@ -35,4 +40,4 @@ class ChatHistoryManager { func deleteHistory(_ history: ChatHistory) { ChatHistoryDatabase.shared?.deleteHistory(history) } -} +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift index 6036131a..40585e5b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift @@ -95,6 +95,13 @@ struct MainTabView: View { navigateToChat = true } } + .onChange(of: navigateToChat) { oldValue, newValue in + if !newValue && oldValue { + refreshHistories() + modelListViewModel.selectedModel = nil + selectedHistory = nil + } + } } // MARK: - View Builders @@ -107,8 +114,7 @@ struct MainTabView: View { .navigationBarHidden(false) .navigationBarTitleDisplayMode(.inline) } else if let history = selectedHistory { - let modelInfo = ModelInfo(modelId: history.modelId, isDownloaded: true) - LLMChatView(modelInfo: modelInfo, history: history) + LLMChatView(modelInfo: history.modelInfo, history: history) .navigationBarHidden(false) .navigationBarTitleDisplayMode(.inline) } else { @@ -168,10 +174,15 @@ struct MainTabView: View { } } + /// Refreshes the histories array. + private func refreshHistories() { + histories = ChatHistoryManager.shared.getAllHistory() + } + /// Handles history toggle with proper memory management. private func handleHistoryToggle(_ isShowing: Bool) { if isShowing { - histories = ChatHistoryManager.shared.getAllHistory() + refreshHistories() } else { Task { @MainActor in try? await Task.sleep(nanoseconds: 300_000_000) // 0.3 seconds From 8248fee4dfc9c489a5546c6b53f009f34d061196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 29 Aug 2025 16:55:20 +0800 Subject: [PATCH 09/25] [refactor] move model downloader to service --- .../Network => Service/ModelDownloader}/AsyncSemaphore.swift | 0 .../ModelDownloader}/DynamicConcurrencyManager.swift | 0 .../Network => Service/ModelDownloader}/ModelClient.swift | 0 .../ModelDownloader}/ModelDownloadConfiguration.swift | 0 .../Network => Service/ModelDownloader}/ModelDownloadLogger.swift | 0 .../ModelDownloader}/ModelDownloadManager.swift | 0 .../ModelDownloader}/ModelDownloadManagerProtocol.swift | 0 .../ModelDownloader}/ModelDownloadStorage.swift | 0 .../ModelDownloader}/ModelScopeDownloadManager.swift | 0 .../Network => Service/ModelDownloader}/ModelScopeModels.swift | 0 .../Network => Service/ModelDownloader}/ModelScopeUtilities.swift | 0 .../MNNLLMChat/MNNLLMiOS/{ => Service}/Util/AssetExtractor.swift | 0 .../MNNLLMiOS/{ => Service}/Util/Attachment+Extension.swift | 0 .../MNNLLMChat/MNNLLMiOS/{ => Service}/Util/Color+Extension.swift | 0 .../MNNLLMiOS/{ => Service}/Util/FileOperationManager.swift | 0 .../MNNLLMChat/MNNLLMiOS/{ => Service}/Util/LanguageManager.swift | 0 .../MNNLLMiOS/{ => Service}/Util/NotificationNames.swift | 0 .../MNNLLMiOS/{ => Service}/Util/String+Extension.swift | 0 .../MNNLLMChat/MNNLLMiOS/{ => Service}/Util/URL+Extension.swift | 0 apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/Util.swift | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/AsyncSemaphore.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/DynamicConcurrencyManager.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelClient.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelDownloadConfiguration.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelDownloadLogger.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelDownloadManager.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelDownloadManagerProtocol.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelDownloadStorage.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelScopeDownloadManager.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelScopeModels.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{MainTab/ModelList/Network => Service/ModelDownloader}/ModelScopeUtilities.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/AssetExtractor.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/Attachment+Extension.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/Color+Extension.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/FileOperationManager.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/LanguageManager.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/NotificationNames.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/String+Extension.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/URL+Extension.swift (100%) rename apps/iOS/MNNLLMChat/MNNLLMiOS/{ => Service}/Util/Util.swift (100%) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/AsyncSemaphore.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/AsyncSemaphore.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/AsyncSemaphore.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/AsyncSemaphore.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/DynamicConcurrencyManager.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/DynamicConcurrencyManager.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/DynamicConcurrencyManager.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelClient.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadConfiguration.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadConfiguration.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadLogger.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadLogger.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadLogger.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadLogger.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManager.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManagerProtocol.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadManagerProtocol.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManagerProtocol.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadStorage.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelDownloadStorage.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadStorage.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeDownloadManager.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeModels.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeModels.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeModels.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeModels.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeUtilities.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeUtilities.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Network/ModelScopeUtilities.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeUtilities.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/AssetExtractor.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/AssetExtractor.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/AssetExtractor.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/AssetExtractor.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/Attachment+Extension.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Attachment+Extension.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/Attachment+Extension.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Attachment+Extension.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/Color+Extension.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Color+Extension.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/Color+Extension.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Color+Extension.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/FileOperationManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/FileOperationManager.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/FileOperationManager.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/FileOperationManager.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/LanguageManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/LanguageManager.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/LanguageManager.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/LanguageManager.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/NotificationNames.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/NotificationNames.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/NotificationNames.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/NotificationNames.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/String+Extension.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/String+Extension.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/URL+Extension.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/URL+Extension.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/URL+Extension.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/URL+Extension.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Util/Util.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Util.swift similarity index 100% rename from apps/iOS/MNNLLMChat/MNNLLMiOS/Util/Util.swift rename to apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Util.swift From fb9732299390cd63d1cf80e073698d110915f497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Mon, 1 Sep 2025 11:27:46 +0800 Subject: [PATCH 10/25] [fix] performance output --- .gitignore | 3 ++ .../LLMInferenceEngineWrapper.mm | 49 +++++++------------ 2 files changed, 22 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 4cfae4d0..6da2bbc3 100644 --- a/.gitignore +++ b/.gitignore @@ -379,3 +379,6 @@ source/backend/qnn/3rdParty/include project/android/.cxx pymnn/android/.cxx/ pymnn/android/.cxx/abi_configuration_5u53tc49.json +apps/iOS/MNNLLMChat/MNNLLMiOS/LocalModel/Qwen3-0.6B-MNN +apps/iOS/MNNLLMChat/Chat/ +apps/iOS/MNNLLMChat/swift-transformers/ diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm b/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm index 61f31836..2d3a910d 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm @@ -698,10 +698,7 @@ bool remove_directory_safely(const std::string& path) { } return; } - - // Performance measurement initialization - auto inference_start_time = std::chrono::high_resolution_clock::now(); - + // Get initial context state BEFORE inference starts auto* context = _llm->getContext(); int initial_prompt_len = 0; @@ -764,13 +761,6 @@ bool remove_directory_safely(const std::string& path) { #else { #endif - // Get initial context state for performance measurement - auto context = blockSelf->_llm->getContext(); - int initial_prompt_len = context->prompt_len; - int initial_decode_len = context->gen_seq_len; - int64_t initial_prefill_time = context->prefill_us; - int64_t initial_decode_time = context->decode_us; - // Reset stop flag before starting inference blockSelf->_shouldStopInference = false; @@ -785,6 +775,7 @@ bool remove_directory_safely(const std::string& path) { // Start inference with initial response processing blockSelf->_llm->response(blockSelf->_history, &os, "", 1); + int current_size = 1; int max_new_tokens = 999999; @@ -798,7 +789,7 @@ bool remove_directory_safely(const std::string& path) { current_size++; // Small delay to allow UI updates and stop signal processing - std::this_thread::sleep_for(std::chrono::milliseconds(1)); + // std::this_thread::sleep_for(std::chrono::milliseconds(1)); } // Send appropriate end signal based on stop reason @@ -835,42 +826,40 @@ bool remove_directory_safely(const std::string& path) { inference_end_time - inference_start_time ); - // Get final context state AFTER inference completes - int final_prompt_len = context->prompt_len; - int final_decode_len = context->gen_seq_len; - int64_t final_prefill_time = context->prefill_us; - int64_t final_decode_time = context->decode_us; + int prompt_len = 0; + int decode_len = 0; + int64_t prefill_time = 0; + int64_t decode_time = 0; - // Calculate differences for this inference session - int current_prompt_len = final_prompt_len - initial_prompt_len; - int current_decode_len = final_decode_len - initial_decode_len; - int64_t current_prefill_time = final_prefill_time - initial_prefill_time; - int64_t current_decode_time = final_decode_time - initial_decode_time; + prompt_len += context->prompt_len; + decode_len += context->gen_seq_len; + prefill_time += context->prefill_us; + decode_time += context->decode_us; // Convert microseconds to seconds - float prefill_s = static_cast(current_prefill_time) / 1e6f; - float decode_s = static_cast(current_decode_time) / 1e6f; + float prefill_s = static_cast(prefill_time) / 1e6f; + float decode_s = static_cast(decode_time) / 1e6f; // Calculate speeds (tokens per second) float prefill_speed = (prefill_s > 0.001f) ? - static_cast(current_prompt_len) / prefill_s : 0.0f; + static_cast(prompt_len) / prefill_s : 0.0f; float decode_speed = (decode_s > 0.001f) ? - static_cast(current_decode_len) / decode_s : 0.0f; + static_cast(decode_len) / decode_s : 0.0f; // Format performance results with better formatting std::ostringstream performance_output; performance_output << "\n\n > Performance Metrics:\n" << "Total inference time: " << total_inference_time.count() << " ms\n" - << " Prompt tokens: " << current_prompt_len << "\n" - << "Generated tokens: " << current_decode_len << "\n" + << "Prompt tokens: " << prompt_len << "\n" + << "Generated tokens: " << decode_len << "\n" << "Prefill time: " << std::fixed << std::setprecision(3) << prefill_s << " s\n" << "Decode time: " << std::fixed << std::setprecision(3) << decode_s << " s\n" << "Prefill speed: " << std::fixed << std::setprecision(1) << prefill_speed << " tok/s\n" << "Decode speed: " << std::fixed << std::setprecision(1) << decode_speed << " tok/s\n"; // Add efficiency metrics - if (current_prompt_len > 0 && current_decode_len > 0) { - float total_tokens = static_cast(current_prompt_len + current_decode_len); + if (prompt_len > 0 && decode_len > 0) { + float total_tokens = static_cast(prompt_len + decode_len); float total_time_s = static_cast(total_inference_time.count()) / 1000.0f; float overall_speed = total_time_s > 0.001f ? total_tokens / total_time_s : 0.0f; From ea6397ecbb059499977c0605662e908e89d737cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Mon, 1 Sep 2025 15:22:02 +0800 Subject: [PATCH 11/25] [update] use phys_footprint to check memory usage --- .../Benchmark/ViewModels/BenchmarkViewModel.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift index 829b999c..53f5a584 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift @@ -417,22 +417,22 @@ class MemoryMonitor: ObservableObject { maxMemoryKb = max(maxMemoryKb, memoryUsage) } - /// Gets current memory usage from system using mach task info + /// Gets current memory usage from system using task_vm_info for physical footprint private func getCurrentMemoryUsage() -> Int64 { - var info = mach_task_basic_info() - var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + var info = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size) / UInt32(MemoryLayout.size) let kerr: kern_return_t = withUnsafeMutablePointer(to: &info) { $0.withMemoryRebound(to: integer_t.self, capacity: 1) { task_info(mach_task_self_, - task_flavor_t(MACH_TASK_BASIC_INFO), + task_flavor_t(TASK_VM_INFO), $0, &count) } } if kerr == KERN_SUCCESS { - return Int64(info.resident_size) / 1024 // Convert to KB + return Int64(info.phys_footprint) / 1024 // Convert to KB } else { return 0 } From 868b6c71a89213105ff1231f3c1724f12f43f64a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Tue, 2 Sep 2025 17:05:14 +0800 Subject: [PATCH 12/25] [feat] Implement stable hash and optimize download progress tracking - Add String.stableHash extension method for generating stable temporary filenames - Refactor download progress tracking to support file-level progress monitoring - Optimize download manager for concurrent downloads and cancellation functionality - Improve temporary file storage location and download cache management - Enhance error handling and resource cleanup logic --- .../ModelList/Models/ModelListViewModel.swift | 20 +-- .../Service/ModelDownloader/ModelClient.swift | 88 +++++++--- .../ModelDownloadConfiguration.swift | 23 ++- .../ModelDownloadManager.swift | 150 ++++++++++++++---- .../ModelScopeDownloadManager.swift | 129 ++++++++++----- .../Service/Util/String+Extension.swift | 15 ++ 6 files changed, 321 insertions(+), 104 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift index 18bca42c..6f644535 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift @@ -21,7 +21,7 @@ class ModelListViewModel: ObservableObject { @Published private(set) var currentlyDownloading: String? // MARK: - Private Properties - private let modelClient = ModelClient() + private let modelClient = ModelClient.shared private let pinnedModelKey = "com.mnnllm.pinnedModelIds" // MARK: - Model Data Access @@ -357,7 +357,10 @@ class ModelListViewModel: ObservableObject { await MainActor.run { guard currentlyDownloading == nil else { return } currentlyDownloading = model.id - downloadProgress[model.id] = 0 + + if downloadProgress[model.id] == nil { + downloadProgress[model.id] = 0 + } } do { @@ -372,6 +375,9 @@ class ModelListViewModel: ObservableObject { self.models[index].isDownloaded = true ModelStorageManager.shared.markModelAsDownloaded(model.modelName) } + + self.downloadProgress.removeValue(forKey: model.id) + self.currentlyDownloading = nil } // Calculate and cache size for newly downloaded model @@ -392,26 +398,22 @@ class ModelListViewModel: ObservableObject { if case ModelScopeError.downloadCancelled = error { print("Download was cancelled") } else { + self.downloadProgress.removeValue(forKey: model.id) self.showError = true self.errorMessage = "Failed to download model: \(error.localizedDescription)" } + self.currentlyDownloading = nil } } - - await MainActor.run { - self.currentlyDownloading = nil - self.downloadProgress.removeValue(forKey: model.id) - } } func cancelDownload() async { let modelId = await MainActor.run { currentlyDownloading } if let modelId = modelId { - await modelClient.cancelDownload() + await modelClient.cancelDownload(for: modelId) await MainActor.run { - self.downloadProgress.removeValue(forKey: modelId) self.currentlyDownloading = nil } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift index 1429043f..89e6e024 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift @@ -28,13 +28,16 @@ import Foundation /// /// Usage: /// ```swift -/// let client = ModelClient() +/// let client = ModelClient.shared /// let models = try await client.getModelInfo() /// try await client.downloadModel(model: selectedModel) { progress in /// print("Download progress: \(progress * 100)%") /// } /// ``` class ModelClient { + // MARK: - Singleton + static let shared = ModelClient() + // MARK: - Properties private let maxRetries = 5 @@ -46,8 +49,9 @@ class ModelClient { // Debug flag to use local mock data instead of network API private let useLocalMockData = false - private var currentDownloadManager: ModelDownloadManagerProtocol? + private var downloadManagers: [String: ModelDownloadManagerProtocol] = [:] private let downloadManagerFactory: ModelDownloadManagerFactory + private let downloadManagerQueue = DispatchQueue(label: "com.mnn.downloadManager", attributes: .concurrent) private lazy var baseURLString: String = { switch ModelSourceManager.shared.selectedSource { @@ -58,14 +62,20 @@ class ModelClient { } }() - /// Creates a ModelClient with dependency injection for download manager + /// Private initializer for singleton pattern /// /// - Parameter downloadManagerFactory: Factory for creating download managers. - /// Defaults to DefaultModelDownloadManagerFactory - init(downloadManagerFactory: ModelDownloadManagerFactory = DefaultModelDownloadManagerFactory()) { + /// Defaults to LegacyModelDownloadManagerFactory + private init(downloadManagerFactory: ModelDownloadManagerFactory = LegacyModelDownloadManagerFactory()) { + print("ModelClient singleton initialized") self.downloadManagerFactory = downloadManagerFactory } + deinit { + print("ModelClient deinit") + downloadManagers.removeAll() + } + /// Retrieves model information from the configured API endpoint /// /// This method fetches the latest model catalog from the network API. @@ -140,15 +150,30 @@ class ModelClient { } } - /// Cancels the current download operation - func cancelDownload() async { - switch ModelSourceManager.shared.selectedSource { - case .modelScope, .modeler: - await currentDownloadManager?.cancelDownload() - case .huggingFace: - // TODO: await currentDownloadManager?.cancelDownload() -// try await mirrorHubApi. - print("cant stop") + /// Cancels download for a specific model + /// - Parameter modelId: The ID of the model to cancel download for + func cancelDownload(for modelId: String) async { + downloadManagerQueue.sync { + if let downloadManager = downloadManagers[modelId] { + Task { + await downloadManager.cancelDownload() + } + } + } + } + + /// Cancels all active downloads + func cancelAllDownloads() async { + let managers = downloadManagerQueue.sync { + return Array(downloadManagers.values) + } + + await withTaskGroup(of: Void.self) { group in + for manager in managers { + group.addTask { + await manager.cancelDownload() + } + } } } @@ -161,14 +186,21 @@ class ModelClient { private func downloadFromModelScope(_ model: ModelInfo, source: ModelSource, progress: @escaping (Double) -> Void) async throws { - - currentDownloadManager = downloadManagerFactory.createDownloadManager( - repoPath: model.id, - source: .modelScope - ) + let downloadManager = downloadManagerQueue.sync { + if let existingManager = downloadManagers[model.id] { + return existingManager + } else { + let newManager = downloadManagerFactory.createDownloadManager( + repoPath: model.id, + source: .modelScope + ) + downloadManagers[model.id] = newManager + return newManager + } + } do { - try await currentDownloadManager?.downloadModel( + try await downloadManager.downloadModel( to: "huggingface/models/taobao-mnn", modelId: model.id, modelName: model.modelName @@ -177,14 +209,30 @@ class ModelClient { progress(fileProgress) } } + + await cleanupDownloadManager(for: model.id) + } catch { if case ModelScopeError.downloadCancelled = error { throw ModelScopeError.downloadCancelled } else { + await cleanupDownloadManager(for: model.id) throw NetworkError.downloadFailed } } } + + private func cleanupDownloadManager(for modelId: String) async { + _ = downloadManagerQueue.sync { + downloadManagers.removeValue(forKey: modelId) + } + } + + func getActiveDownloadersCount() -> Int { + return downloadManagerQueue.sync { + return downloadManagers.count + } + } /// Downloads model from HuggingFace platform with optimized progress updates /// diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift index 2534c129..b6eddb09 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadConfiguration.swift @@ -46,11 +46,32 @@ struct ChunkInfo { struct DownloadProgress { var totalBytes: Int64 = 0 - var downloadedBytes: Int64 = 0 var activeDownloads: Int = 0 var completedFiles: Int = 0 var totalFiles: Int = 0 + // Track individual file progress + var fileProgress: [String: FileDownloadProgress] = [:] + var lastReportedProgress: Double = 0.0 + + var progress: Double { + guard totalBytes > 0 else { return 0.0 } + + let totalDownloadedBytes = fileProgress.values.reduce(0) { sum, fileProgress in + return sum + fileProgress.downloadedBytes + } + + let calculatedProgress = Double(totalDownloadedBytes) / Double(totalBytes) + return min(calculatedProgress, 1.0) // Ensure progress never exceeds 100% + } +} + +struct FileDownloadProgress { + let fileName: String + let totalBytes: Int64 + var downloadedBytes: Int64 = 0 + var isCompleted: Bool = false + var progress: Double { guard totalBytes > 0 else { return 0.0 } return Double(downloadedBytes) / Double(totalBytes) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift index 8e006f3f..888a02ef 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift @@ -100,7 +100,8 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { self.session = URLSession(configuration: sessionConfig) self.concurrencyManager = DynamicConcurrencyManager(config: concurrencyConfig) self.downloadSemaphore = AsyncSemaphore(value: config.maxConcurrentDownloads) - + print("ModelClient init") + ModelDownloadLogger.isEnabled = enableLogging } @@ -216,6 +217,10 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { let subFiles = try await fetchFileList(root: file.path, revision: "") try await processFiles(subFiles, destinationPath: newPath) } else if file.type == "blob" { + // Initialize progress tracking for all files + await initializeFileProgress(fileName: file.name, totalBytes: Int64(file.size)) + progress.totalBytes += Int64(file.size) + if !storage.isFileDownloaded(file, at: destinationPath) { var task = DownloadTask( file: file, @@ -230,9 +235,14 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { } downloadQueue.append(task) - progress.totalBytes += Int64(file.size) } else { - progress.downloadedBytes += Int64(file.size) + // File already downloaded, mark as completed in progress tracking + if var fileProgress = progress.fileProgress[file.name] { + fileProgress.downloadedBytes = fileProgress.totalBytes + fileProgress.isCompleted = true + progress.fileProgress[file.name] = fileProgress + } + ModelDownloadLogger.info("File \(file.name) already exists, skipping download") } } } @@ -261,9 +271,12 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { let startOffset = Int64(i) * chunkSize let endOffset = min(startOffset + chunkSize - 1, fileSize - 1) - let modelHash = repoPath.hash - let fileHash = file.path.hash - let tempURL = FileManager.default.temporaryDirectory + let modelHash = repoPath.stableHash + let fileHash = file.path.stableHash + + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true) + let tempURL = downloadsURL .appendingPathComponent("model_\(modelHash)_file_\(fileHash)_chunk_\(i)_\(file.name.sanitizedPath).tmp") // Check if chunk already exists and calculate downloaded bytes @@ -294,6 +307,31 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { return chunks } + /// Calculate initial progress from existing downloaded files and chunks + private func calculateInitialProgress() async { + for task in downloadQueue { + if task.isChunked { + // For chunked files, sum up downloaded bytes from all chunks + let chunkBytes = task.chunks.reduce(0) { total, chunk in + return total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes) + } + + // Update file progress with chunk data + if var fileProgress = progress.fileProgress[task.file.name] { + fileProgress.downloadedBytes = chunkBytes + progress.fileProgress[task.file.name] = fileProgress + } + } + // For non-chunked files, if they exist, they would not be in downloadQueue + } + + let totalDownloadedBytes = progress.fileProgress.values.reduce(0) { sum, fileProgress in + return sum + fileProgress.downloadedBytes + } + + ModelDownloadLogger.info("Initial downloaded bytes: \(totalDownloadedBytes)") + } + // MARK: - Download Execution /// Executes download tasks with dynamic concurrency management @@ -304,6 +342,9 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { /// /// - Throws: ModelScopeError if downloads fail or are cancelled private func executeDownloads() async throws { + // Calculate initial downloaded bytes from existing files and chunks + await calculateInitialProgress() + await withTaskGroup(of: Void.self) { group in for task in downloadQueue { if isCancelled { break } @@ -350,12 +391,11 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { ModelDownloadLogger.info("Using optimal concurrency: \(concurrencyCount) for \(task.chunks.count) chunks") - // Check if any chunks are already completed and update progress + // Check if any chunks are already completed and log progress (but don't update global progress yet) let completedBytes = task.chunks.reduce(0) { total, chunk in return total + (chunk.isCompleted ? (chunk.endOffset - chunk.startOffset + 1) : chunk.downloadedBytes) } if completedBytes > 0 { - await updateDownloadProgress(bytes: completedBytes) ModelDownloadLogger.info("Found \(completedBytes) bytes already downloaded for \(task.file.name)") } @@ -435,22 +475,26 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { try fileHandle.seekToEnd() } - var bytesCount = 0 + var buffer = Data() + buffer.reserveCapacity(512 * 1024) // Reserve 512KB buffer for chunks for try await byte in asyncBytes { if isCancelled { throw ModelScopeError.downloadCancelled } - try fileHandle.write(contentsOf: [byte]) - bytesCount += 1 + buffer.append(byte) - if bytesCount >= 64 * 1024 { - await updateDownloadProgress(bytes: Int64(bytesCount)) - bytesCount = 0 + // Write in larger chunks to reduce I/O operations + if buffer.count >= 128 * 1024 { // 128KB chunks for chunk downloads + try fileHandle.write(contentsOf: buffer) + await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count)) + buffer.removeAll(keepingCapacity: true) } } - if bytesCount > 0 { - await updateDownloadProgress(bytes: Int64(bytesCount)) + // Write remaining buffer + if !buffer.isEmpty { + try fileHandle.write(contentsOf: buffer) + await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count)) } ModelDownloadLogger.info("Chunk \(chunk.index) downloaded successfully") @@ -498,9 +542,15 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { let destination = URL(fileURLWithPath: task.destinationPath) .appendingPathComponent(file.name.sanitizedPath) - let modelHash = repoPath.hash - let fileHash = file.path.hash - let tempURL = FileManager.default.temporaryDirectory + let modelHash = repoPath.stableHash + let fileHash = file.path.stableHash + + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true) + + try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true, attributes: nil) + + let tempURL = downloadsURL .appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp") var lastError: Error? @@ -534,27 +584,32 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { } var downloadedBytes: Int64 = resumeOffset - var bytesCount = 0 + var buffer = Data() + buffer.reserveCapacity(1024 * 1024) // Reserve 1MB buffer for try await byte in asyncBytes { if isCancelled { throw ModelScopeError.downloadCancelled } - try fileHandle.write(contentsOf: [byte]) - downloadedBytes += 1 - bytesCount += 1 + buffer.append(byte) - // Update progress less frequently - if bytesCount >= 64 * 1024 { - await updateDownloadProgress(bytes: Int64(bytesCount)) - bytesCount = 0 + // Write in larger chunks to reduce I/O operations + if buffer.count >= 256 * 1024 { // 256KB chunks + try fileHandle.write(contentsOf: buffer) + downloadedBytes += Int64(buffer.count) + await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count)) + buffer.removeAll(keepingCapacity: true) } } - // Final progress update - if bytesCount > 0 { - await updateDownloadProgress(bytes: Int64(bytesCount)) + // Write remaining buffer + if !buffer.isEmpty { + try fileHandle.write(contentsOf: buffer) + downloadedBytes += Int64(buffer.count) + await updateFileProgress(fileName: file.name, bytes: Int64(buffer.count)) } + // Progress already updated in the loop above + // Move to final destination if fileManager.fileExists(atPath: destination.path) { try fileManager.removeItem(at: destination) @@ -658,13 +713,42 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { private func markFileCompleted(task: DownloadTask) async { progress.completedFiles += 1 + + // Mark file as completed in progress tracking + if var fileProgress = progress.fileProgress[task.file.name] { + fileProgress.downloadedBytes = fileProgress.totalBytes + fileProgress.isCompleted = true + progress.fileProgress[task.file.name] = fileProgress + } + storage.saveFileStatus(task.file, at: task.destinationPath) ModelDownloadLogger.info("Completed: \(task.file.name) (\(progress.completedFiles)/\(progress.totalFiles))") + + await updateProgress(progress.progress) } - private func updateDownloadProgress(bytes: Int64) async { - progress.downloadedBytes += bytes - await updateProgress(progress.progress) + private func updateFileProgress(fileName: String, bytes: Int64) async { + if var fileProgress = progress.fileProgress[fileName] { + fileProgress.downloadedBytes = min(fileProgress.downloadedBytes + bytes, fileProgress.totalBytes) + progress.fileProgress[fileName] = fileProgress + + let newProgress = progress.progress + let progressDiff = abs(newProgress - progress.lastReportedProgress) + if progressDiff >= 0.01 || newProgress >= 1.0 { + progress.lastReportedProgress = newProgress + await updateProgress(newProgress) + } + } + } + + private func initializeFileProgress(fileName: String, totalBytes: Int64) async { + let fileProgress = FileDownloadProgress( + fileName: fileName, + totalBytes: totalBytes, + downloadedBytes: 0, + isCompleted: false + ) + progress.fileProgress[fileName] = fileProgress } private func updateProgress(_ value: Double) async { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift index f85efd65..c680cab1 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift @@ -62,7 +62,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { // MARK: - Properties private let repoPath: String - private let session: URLSession + private var session: URLSession + private let sessionConfig: URLSessionConfiguration private let fileManager: FileManager private let storage: ModelDownloadStorage @@ -72,6 +73,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { private var totalSize: Int64 = 0 private var downloadedSize: Int64 = 0 private var lastUpdatedBytes: Int64 = 0 + private var lastReportedProgress: Double = 0.0 // Download cancellation related properties private var isCancelled: Bool = false @@ -98,9 +100,17 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { self.repoPath = repoPath self.fileManager = .default self.storage = ModelDownloadStorage() + self.sessionConfig = config self.session = URLSession(configuration: config) self.source = source ModelDownloadLogger.isEnabled = enableLogging + print("ModelScopeDownloadManager init") + } + + deinit { + // Clean up session when the manager is deallocated + session.invalidateAndCancel() + print("ModelScopeDownloadManager deinit") } // MARK: - Public Methods @@ -135,6 +145,9 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { progress: ((Double) -> Void)? = nil ) async throws { + // Ensure we have a valid session before starting download + ensureValidSession() + isCancelled = false ModelDownloadLogger.info("Starting download for modelId: \(modelId)") @@ -158,7 +171,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { /// /// This method gracefully stops all active downloads, closes file handles, /// and preserves temporary files to enable resume functionality in future attempts. - /// The URLSession is invalidated to ensure clean cancellation. + /// The URLSession is kept valid to allow future downloads. public func cancelDownload() async { isCancelled = true @@ -167,7 +180,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { await closeFileHandle() - session.invalidateAndCancel() + // Don't invalidate session to allow future downloads + // session.invalidateAndCancel() ModelDownloadLogger.info("Download cancelled, temporary files preserved for resume") } @@ -180,11 +194,25 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { /// - progress: Current progress value (0.0 to 1.0) /// - callback: Progress callback function to invoke on main thread private func updateProgress(_ progress: Double, callback: @escaping (Double) -> Void) { - Task { @MainActor in - callback(progress) + // Only update UI progress if there's a significant change (>0.1%) + let progressDiff = abs(progress - lastReportedProgress) + if progressDiff >= 0.01 || progress >= 1.0 { + lastReportedProgress = progress + Task { @MainActor in + callback(progress) + } } } + /// Ensures the URLSession is valid by recreating it if necessary + /// + /// This method provides a simple and reliable way to ensure we have a valid URLSession + /// by always creating a fresh session before downloads. This prevents any potential + /// "Task created in a session that has been invalidated" errors. + private func ensureValidSession() { + session = URLSession(configuration: sessionConfig) + } + /// Fetches the complete file list from ModelScope or Modeler repository /// /// This method queries the repository API to discover all available files, @@ -215,28 +243,28 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } - /** - * Downloads a single file with intelligent resume and retry mechanisms - * - * This method handles individual file downloads with comprehensive error recovery, - * resume functionality through temporary files, and progress tracking. It supports - * both ModelScope and Modeler platforms with platform-specific URL construction. - * - * Features: - * - Automatic resume from temporary files using HTTP Range requests - * - Exponential backoff retry mechanism (configurable attempts) - * - Memory-efficient streaming using URLSession.bytes - * - File integrity validation using size verification - * - Progress update throttling to prevent UI blocking - * - Graceful cancellation with state preservation - * - * @param file ModelFile metadata including path, size, and download information - * @param destinationPath Target local path for the downloaded file - * @param onProgress Progress callback receiving downloaded bytes count - * @param maxRetries Maximum number of retry attempts (default: 3) - * @param retryDelay Delay between retry attempts in seconds (default: 2.0) - * @throws ModelScopeError if download fails after all retry attempts - */ + /// Downloads a single file with intelligent resume and retry mechanisms. + /// + /// This method handles individual file downloads with comprehensive error recovery, + /// resume functionality through temporary files, and progress tracking. It supports + /// both ModelScope and Modeler platforms with platform-specific URL construction. + /// + /// - Features: + /// - Automatic resume from temporary files using HTTP Range requests + /// - Exponential backoff retry mechanism (configurable attempts) + /// - Memory-efficient streaming using URLSession.bytes + /// - File integrity validation using size verification + /// - Progress update throttling to prevent UI blocking + /// - Graceful cancellation with state preservation + /// + /// - Parameters: + /// - file: ModelFile metadata including path, size, and download information. + /// - destinationPath: Target local path for the downloaded file. + /// - onProgress: Progress callback receiving downloaded bytes count. + /// - maxRetries: Maximum number of retry attempts. Defaults to 3. + /// - retryDelay: Delay between retry attempts in seconds. Defaults to 2.0. + /// + /// - Throws: `ModelScopeError` if download fails after all retry attempts. private func downloadFile( file: ModelFile, destinationPath: String, @@ -293,9 +321,15 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { let destination = URL(fileURLWithPath: destinationPath) .appendingPathComponent(file.name.sanitizedPath) - let modelHash = repoPath.hash - let fileHash = file.path.hash - let tempURL = FileManager.default.temporaryDirectory + let modelHash = repoPath.stableHash + let fileHash = file.path.stableHash + + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true) + + try? fileManager.createDirectory(at: downloadsURL, withIntermediateDirectories: true, attributes: nil) + + let tempURL = downloadsURL .appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp") var resumeOffset: Int64 = 0 @@ -346,7 +380,8 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { } var downloadedBytes: Int64 = resumeOffset - var bytesCount = 0 + var buffer = Data() + buffer.reserveCapacity(1024 * 1024) // Reserve 1MB buffer for try await byte in asyncBytes { // Frequently check cancellation status @@ -358,17 +393,24 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { return } - try fileHandle.write(contentsOf: [byte]) - downloadedBytes += 1 - bytesCount += 1 + buffer.append(byte) - // Reduce progress callback frequency: update every 64KB * 5 instead of every 1KB - if bytesCount >= 64 * 1024 * 5 { + // Write in larger chunks to reduce I/O operations + if buffer.count >= 256 * 1024 { // 256KB chunks + try fileHandle.write(contentsOf: buffer) + downloadedBytes += Int64(buffer.count) onProgress(downloadedBytes) - bytesCount = 0 + buffer.removeAll(keepingCapacity: true) } } + // Write remaining buffer + if !buffer.isEmpty { + try fileHandle.write(contentsOf: buffer) + downloadedBytes += Int64(buffer.count) + onProgress(downloadedBytes) + } + try fileHandle.close() self.currentFileHandle = nil @@ -467,11 +509,13 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { ModelDownloadLogger.debug("Downloading: \(file.name)") if !storage.isFileDownloaded(file, at: destinationPath) { + let fileStartSize = downloadedSize try await downloadFile( file: file, destinationPath: destinationPath, onProgress: { downloadedBytes in - let currentProgress = Double(self.downloadedSize + downloadedBytes) / Double(self.totalSize) + // 使用文件开始时的已下载大小 + 当前文件的下载字节数 + let currentProgress = Double(fileStartSize + downloadedBytes) / Double(self.totalSize) self.updateProgress(currentProgress, callback: progress) }, maxRetries: 500, // Can be made configurable @@ -683,9 +727,12 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { /// - destinationPath: Destination path used for temp file naming /// - Returns: Size of temporary file in bytes, or 0 if file doesn't exist private func getTempFileSize(for file: ModelFile, destinationPath: String) -> Int64 { - let modelHash = repoPath.hash - let fileHash = file.path.hash - let tempURL = FileManager.default.temporaryDirectory + let modelHash = repoPath.stableHash + let fileHash = file.path.stableHash + + let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let downloadsURL = documentsURL.appendingPathComponent(".downloads", isDirectory: true) + let tempURL = downloadsURL .appendingPathComponent("model_\(modelHash)_file_\(fileHash)_\(file.name.sanitizedPath).tmp") guard fileManager.fileExists(atPath: tempURL.path) else { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift index dba57ded..ad1a2711 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/String+Extension.swift @@ -6,8 +6,23 @@ // import Foundation +import CryptoKit extension String { + + /// Generate a stable hash value for temporary file naming + /// + /// Unlike the hash property in Swift's standard library, this method + /// generates the same hash value for identical strings across different + /// app launches, ensuring that resumable download functionality works correctly. + /// + /// - Returns: A stable hash value based on SHA256 + var stableHash: String { + let data = self.data(using: .utf8) ?? Data() + let digest = SHA256.hash(data: data) + return digest.compactMap { String(format: "%02x", $0) }.joined().prefix(16).description + } + func removingTaobaoPrefix() -> String { return self.replacingOccurrences(of: "taobao-mnn/", with: "") } From 2f6eaed301106e5f42cc4c06b44f6a993c2126e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Tue, 2 Sep 2025 19:34:36 +0800 Subject: [PATCH 13/25] [update] use full with omni model --- apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift index 68b50ad0..8f8abcd6 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift @@ -38,6 +38,7 @@ struct LLMChatView: View { viewModel.currentStreamingMessageId } .setAvailableInput( + self.title.lowercased().contains("omni") ? .full: self.title.lowercased().contains("vl") ? .textAndMedia : self.title.lowercased().contains("audio") ? .textAndAudio : (self.title.isEmpty ? .textOnly : .textOnly) From a75a59c0eca3bfe6880ec41b488fbd0f0397564f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 3 Sep 2025 15:42:03 +0800 Subject: [PATCH 14/25] [update] performance formate --- .../LLMInferenceEngineWrapper.mm | 24 ++++--------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm b/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm index 2d3a910d..3851380a 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/InferenceEngine/LLMInferenceEngineWrapper.mm @@ -846,26 +846,12 @@ bool remove_directory_safely(const std::string& path) { float decode_speed = (decode_s > 0.001f) ? static_cast(decode_len) / decode_s : 0.0f; - // Format performance results with better formatting + // Format performance results in 2-line format std::ostringstream performance_output; - performance_output << "\n\n > Performance Metrics:\n" - << "Total inference time: " << total_inference_time.count() << " ms\n" - << "Prompt tokens: " << prompt_len << "\n" - << "Generated tokens: " << decode_len << "\n" - << "Prefill time: " << std::fixed << std::setprecision(3) << prefill_s << " s\n" - << "Decode time: " << std::fixed << std::setprecision(3) << decode_s << " s\n" - << "Prefill speed: " << std::fixed << std::setprecision(1) << prefill_speed << " tok/s\n" - << "Decode speed: " << std::fixed << std::setprecision(1) << decode_speed << " tok/s\n"; - - // Add efficiency metrics - if (prompt_len > 0 && decode_len > 0) { - float total_tokens = static_cast(prompt_len + decode_len); - float total_time_s = static_cast(total_inference_time.count()) / 1000.0f; - float overall_speed = total_time_s > 0.001f ? total_tokens / total_time_s : 0.0f; - - performance_output << "> Overall speed: " << std::fixed << std::setprecision(1) - << overall_speed << " tok/s\n"; - } + performance_output << "\n\nPrefill: " << std::fixed << std::setprecision(2) << prefill_s << "s, " + << prompt_len << " tokens, " << std::setprecision(2) << prefill_speed << " tokens/s\n" + << "Decode: " << std::fixed << std::setprecision(2) << decode_s << "s, " + << decode_len << " tokens, " << std::setprecision(2) << decode_speed << " tokens/s\n"; // Output performance results on main queue std::string perf_str = performance_output.str(); From 45af751c3456a5899e0e3caee1b500f510372c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 3 Sep 2025 15:44:40 +0800 Subject: [PATCH 15/25] [feat] change tab icon --- .../benchmark.imageset/Contents.json | 12 +++++++++ .../benchmark.imageset/benchmark.pdf | Bin 0 -> 4282 bytes .../benchmarkFill.imageset/Contents.json | 12 +++++++++ .../benchmarkFill.imageset/benchmarkFill.pdf | Bin 0 -> 4122 bytes .../home.imageset/Contents.json | 12 +++++++++ .../Assets.xcassets/home.imageset/home.pdf | Bin 0 -> 3970 bytes .../homeFill.imageset/Contents.json | 12 +++++++++ .../homeFill.imageset/homeFill.pdf | Bin 0 -> 3923 bytes .../market.imageset/Contents.json | 12 +++++++++ .../market.imageset/market.pdf | Bin 0 -> 5977 bytes .../marketFill.imageset/Contents.json | 12 +++++++++ .../marketFill.imageset/marketFill.pdf | Bin 0 -> 5460 bytes .../MNNLLMiOS/MainTab/MainTabItem.swift | 25 ++++++++++++++++++ .../MNNLLMiOS/MainTab/MainTabView.swift | 25 ++++++++++++------ 14 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/Contents.json create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/benchmark.pdf create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/Contents.json create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/benchmarkFill.pdf create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/home.imageset/Contents.json create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/home.imageset/home.pdf create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/Contents.json create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/homeFill.pdf create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/Contents.json create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/market.pdf create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/Contents.json create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/marketFill.pdf create mode 100644 apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabItem.swift diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/Contents.json b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/Contents.json new file mode 100644 index 00000000..840c1c71 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "benchmark.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/benchmark.pdf b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmark.imageset/benchmark.pdf new file mode 100644 index 0000000000000000000000000000000000000000..fd55199b963b2a169dc7047c2ba9526176892c33 GIT binary patch literal 4282 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 literal 0 HcmV?d00001 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/Contents.json b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/Contents.json new file mode 100644 index 00000000..f763e9c0 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "benchmarkFill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/benchmarkFill.pdf b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/benchmarkFill.imageset/benchmarkFill.pdf new file mode 100644 index 0000000000000000000000000000000000000000..8e5fc245845109b107028bc6283d18eece91f608 GIT binary patch literal 4122 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 R%^p%2sSK5r)HgGL{s+%vzz6^U literal 0 HcmV?d00001 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/Contents.json b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/Contents.json new file mode 100644 index 00000000..dbf68c8c --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "homeFill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/homeFill.pdf b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/homeFill.imageset/homeFill.pdf new file mode 100644 index 0000000000000000000000000000000000000000..6daf6d3c40cc660a9c0b08dc5beba24b3f1b98a7 GIT binary patch literal 3923 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 literal 0 HcmV?d00001 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/Contents.json b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/Contents.json new file mode 100644 index 00000000..f590291e --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "market.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/market.pdf b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/market.imageset/market.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a78e030e12172909c6c93d800281522f38e40d43 GIT binary patch literal 5977 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 S-@6AB5yqdSb1G^o0sjl#AFrkW literal 0 HcmV?d00001 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/Contents.json b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/Contents.json new file mode 100644 index 00000000..07e95110 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "marketFill.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/marketFill.pdf b/apps/iOS/MNNLLMChat/MNNLLMiOS/Assets.xcassets/marketFill.imageset/marketFill.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b67b946633f3867460c0b1e6cd43f650b9126a9b GIT binary patch literal 5460 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 `ZT6h{Ir!9dmAyQQ=%AZ7qY$LALKkUBpk{Y<-b# zyYmRX*|6`?zIngyB7M0sq2mg2{mdF8yl~WlTur#lVJ3_tKHC%OcyMX+Np=&ta4|+w zMr9-aK3M9)<-%cSxqus>$M>RKzVc$~WX7qi{ctC5#tvQg#5pmlNm=+Z?X+62_X@fU z&97Rox}{dBhDM#D=7Vbw`@g-w=%wYx%LhEXdj2ht%fo4H@i^i; z=AT{}XH{|D7VBpvz z`Z-{4uy}r6T7f_y4;(;o;D7@?92nsM=%el|fD6Dl5XXTK4n+SIiC_u9ARl$3V_ZrE z2VyvY;6MroU|hwo@j%?C0S^3d@M}UJT!g?uFb;l|=;NYa1gf}583$fC@WjEdVi+z8 z{PoET5LU)swISeMo)FwofCq9%1SBF3k_L%_Bt^x=BrHH6ZfxVlF1DBB|6Kz3_j(2T zz?=XepfmtG(O(ZxLR?%_9Owl6D+iX8#{LG}1N8hmCnh0{{n!7Q!>-Fea$qUR|1wtm zA33nJ72LJS6$5(9&rq(Bmml5&9mzvRyi>WhH- TAbzc$I9Nmiz{#nirwaHVnMRiK literal 0 HcmV?d00001 diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabItem.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabItem.swift new file mode 100644 index 00000000..27b0e4f0 --- /dev/null +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabItem.swift @@ -0,0 +1,25 @@ +// +// MainTabItem.swift +// MNNLLMiOS +// +// Created by 游薪渝(揽清) on 2025/9/3. +// + +import SwiftUI + +struct MainTabItem: View { + let imageName: String + let title: String + let isSelected: Bool + + var body: some View { + VStack { + Image(imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 10, height: 10) + Text(title) + } + .foregroundColor(isSelected ? .primaryPurple : .gray) + } +} diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift index 40585e5b..f8a561e8 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/MainTabView.swift @@ -27,7 +27,7 @@ struct MainTabView: View { private var titles: [String] { [ - NSLocalizedString("Local Model", comment: "本地模型标签"), + NSLocalizedString("My Model", comment: "我的模型标签"), NSLocalizedString("Model Market", comment: "模型市场标签"), NSLocalizedString("Benchmark", comment: "基准测试标签") ] @@ -40,21 +40,21 @@ struct MainTabView: View { createTabContent( content: LocalModelListView(viewModel: modelListViewModel), title: titles[0], - icon: "house.fill", + icon: "home", tag: 0 ) createTabContent( content: ModelListView(viewModel: modelListViewModel), title: titles[1], - icon: "doc.text.fill", + icon: "market", tag: 1 ) createTabContent( content: BenchmarkView(), title: titles[2], - icon: "clock.fill", + icon: "benchmark", tag: 2 ) } @@ -143,17 +143,26 @@ struct MainTabView: View { showHistoryButton: $showHistoryButton ) } - .navigationDestination(isPresented: $navigateToChat) { + .navigationDestination(isPresented: Binding( + get: { navigateToChat && selectedTab == tag }, + set: { _ in navigateToChat = false } + )) { chatDestination } - .navigationDestination(isPresented: $navigateToSettings) { + .navigationDestination(isPresented: Binding( + get: { navigateToSettings && selectedTab == tag }, + set: { _ in navigateToSettings = false } + )) { SettingsView() } .toolbar((navigateToChat || navigateToSettings) ? .hidden : .visible, for: .tabBar) } .tabItem { - Image(systemName: icon) - Text(title) + MainTabItem( + imageName: selectedTab == tag ? "\(icon)Fill" : icon, + title: title, + isSelected: selectedTab == tag + ) } .tag(tag) } From 51011f9261b058bf44ec400439205e525db83739 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 3 Sep 2025 16:10:21 +0800 Subject: [PATCH 16/25] [update] benchmark localiztion --- .../MNNLLMiOS/Localizable.xcstrings | 167 +++++++++++++++--- .../ModelSelectionCard.swift | 16 +- .../PerformanceMetricView.swift | 16 +- .../BenchmarkSubViews/ProgressCard.swift | 8 +- .../Views/BenchmarkSubViews/ResultsCard.swift | 48 ++--- .../Views/BenchmarkSubViews/StatusCard.swift | 2 +- .../Benchmark/Views/BenchmarkView.swift | 12 +- .../Views/LocalModelListView.swift | 2 +- 8 files changed, 194 insertions(+), 77 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings b/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings index b2e3805f..62559b53 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings @@ -69,7 +69,14 @@ } }, "Audio Message" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语音信息" + } + } + } }, "Benchmark" : { "comment" : "基准测试标签", @@ -112,7 +119,7 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "清除" } } @@ -144,7 +151,7 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "完成" } } @@ -218,17 +225,6 @@ } } }, - "Chat" : { - "extractionState" : "stale", - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "对话" - } - } - } - }, "ChatHistroyTitle" : { "extractionState" : "manual", "localizations" : { @@ -283,6 +279,16 @@ } } }, + "Complete duration" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成时长" + } + } + } + }, "Completed" : { "localizations" : { "zh-Hans" : { @@ -303,6 +309,16 @@ } } }, + "Decode Speed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解码速度" + } + } + } + }, "Delete" : { "localizations" : { "zh-Hans" : { @@ -380,7 +396,7 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "按标签筛选" } } @@ -396,7 +412,7 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "按厂商筛选" } } @@ -412,12 +428,22 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "筛选选项" } } } }, + "Generation rate" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "生成速率" + } + } + } + }, "Help" : { "localizations" : { "zh-Hans" : { @@ -490,13 +516,12 @@ } } }, - "Local Model" : { - "comment" : "本地模型标签", + "Memory Usage" : { "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "本地模型" + "value" : "内存使用" } } } @@ -583,12 +608,33 @@ }, "zh-Hans" : { "stringUnit" : { - "state" : "needs_review", + "state" : "translated", "value" : "下载源" } } } }, + "My Model" : { + "comment" : "我的模型标签", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "我的模型" + } + } + } + }, + "N/A" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不可用" + } + } + } + }, "No" : { "localizations" : { "zh-Hans" : { @@ -629,6 +675,16 @@ } } }, + "Peak memory" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "峰值内存" + } + } + } + }, "Penalty Sampler" : { }, @@ -655,8 +711,25 @@ } } }, + "Prefill Speed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预填充速度" + } + } + } + }, "Progress" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "进展" + } + } + } }, "Random Seed" : { "localizations" : { @@ -669,7 +742,14 @@ } }, "Ready" : { - + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "准备" + } + } + } }, "Running performance tests" : { "localizations" : { @@ -1240,6 +1320,37 @@ } } }, + "Tokens per second" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "每秒令牌数" + } + } + } + }, + "Total Time" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "总时间" + } + } + } + }, + "Total Tokens" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "总令牌数" + } + } + } + }, "Use HuggingFace to download" : { "localizations" : { "zh-Hans" : { @@ -1317,12 +1428,18 @@ } } }, - "搜索本地模型..." : { + "搜索模型..." : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search local models …" + "value" : "Search Models…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索模型..." } } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift index 0379655c..c0eacafc 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ModelSelectionCard.swift @@ -15,7 +15,7 @@ struct ModelSelectionCard: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { - Text("Select Model") + Text(String(localized: "Select Model")) .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) @@ -27,7 +27,7 @@ struct ModelSelectionCard: View { HStack { ProgressView() .scaleEffect(0.8) - Text("Loading models...") + Text(String(localized: "Loading models...")) .font(.subheadline) .foregroundColor(.secondary) } @@ -56,7 +56,7 @@ struct ModelSelectionCard: View { private var modelDropdownMenu: some View { Menu { if viewModel.availableModels.isEmpty { - Button("No models available") { + Button(String(localized: "No models available")) { // Placeholder - no action } .disabled(true) @@ -69,7 +69,7 @@ struct ModelSelectionCard: View { VStack(alignment: .leading, spacing: 2) { Text(model.modelName) .font(.system(size: 14, weight: .medium)) - Text("Local") + Text(String(localized: "Local")) .font(.caption) .foregroundColor(.secondary) } @@ -91,7 +91,7 @@ struct ModelSelectionCard: View { Circle() .fill(Color.benchmarkSuccess) .frame(width: 6, height: 6) - Text("Ready") + Text(String(localized: "Ready")) .font(.caption) .foregroundColor(.benchmarkSuccess) } @@ -103,7 +103,7 @@ struct ModelSelectionCard: View { } } } else { - Text("Tap to select a model for testing") + Text(String(localized: "Tap to select a model for testing")) .font(.caption) .foregroundColor(.benchmarkSecondary) } @@ -202,12 +202,12 @@ struct ModelSelectionCard: View { private var statusMessages: some View { Group { if viewModel.selectedModel == nil { - Text("Start benchmark after selecting your model") + Text(String(localized: "Start benchmark after selecting your model")) .font(.caption) .foregroundColor(.orange) .padding(.horizontal, 16) } else if viewModel.availableModels.isEmpty { - Text("No local models found. Please download a model first.") + Text(String(localized: "No local models found. Please download a model first.")) .font(.caption) .foregroundColor(.orange) .padding(.horizontal, 16) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift index 0fda06fa..9e5fecdc 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/PerformanceMetricView.swift @@ -78,17 +78,17 @@ struct PerformanceMetricView: View { HStack(spacing: 12) { PerformanceMetricView( icon: "speedometer", - title: "Prefill Speed", + title: String(localized: "Prefill Speed"), value: "1024.5 t/s", - subtitle: "Tokens per second", + subtitle: String(localized: "Tokens per second"), color: .benchmarkGradientStart ) PerformanceMetricView( icon: "gauge", - title: "Decode Speed", + title: String(localized: "Decode Speed"), value: "109.8 t/s", - subtitle: "Generation rate", + subtitle: String(localized: "Generation rate"), color: .benchmarkGradientEnd ) } @@ -96,17 +96,17 @@ struct PerformanceMetricView: View { HStack(spacing: 12) { PerformanceMetricView( icon: "memorychip", - title: "Memory Usage", + title: String(localized: "Memory Usage"), value: "1.2 GB", - subtitle: "Peak memory", + subtitle: String(localized: "Peak memory"), color: .benchmarkWarning ) PerformanceMetricView( icon: "clock", - title: "Total Time", + title: String(localized: "Total Time"), value: "2.456s", - subtitle: "Complete duration", + subtitle: String(localized: "Complete duration"), color: .benchmarkSuccess ) } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift index d5a4e1bd..32056dff 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ProgressCard.swift @@ -60,12 +60,12 @@ struct ProgressCard: View { } VStack(alignment: .leading, spacing: 2) { - Text("Test Progress") + Text(String(localized: "Test Progress")) .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) - Text("Running performance tests") + Text(String(localized: "Running performance tests")) .font(.caption) .foregroundColor(.benchmarkSecondary) } @@ -79,7 +79,7 @@ struct ProgressCard: View { .fontWeight(.bold) .foregroundColor(.benchmarkAccent) - Text("Complete") + Text(String(localized: "Complete")) .font(.caption) .foregroundColor(.benchmarkSecondary) } @@ -156,7 +156,7 @@ struct ProgressCard: View { private var fallbackProgress: some View { VStack(alignment: .leading, spacing: 8) { - Text("Progress") + Text(String(localized: "Progress")) .font(.headline) ProgressView() .progressViewStyle(LinearProgressViewStyle()) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift index 44a342a3..fe6c0347 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift @@ -43,7 +43,7 @@ struct ResultsCard: View { .font(.subheadline) .foregroundColor(.secondary) - Text("Benchmark Config") + Text(String(localized: "Benchmark Config")) .font(.headline) Text(statistics.configText) .font(.subheadline) @@ -73,12 +73,12 @@ struct ResultsCard: View { } VStack(alignment: .leading, spacing: 2) { - Text("Benchmark Results") + Text(String(localized: "Benchmark Results")) .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) - Text("Performance analysis complete") + Text(String(localized: "Performance analysis complete")) .font(.caption) .foregroundColor(.benchmarkSecondary) } @@ -94,7 +94,7 @@ struct ResultsCard: View { .font(.title2) .foregroundColor(.benchmarkSuccess) - Text("Share") + Text(String(localized: "Share")) .font(.caption) .foregroundColor(.benchmarkSecondary) } @@ -112,17 +112,17 @@ struct ResultsCard: View { if let prefillStats = statistics.prefillStats { PerformanceMetricView( icon: "speedometer", - title: "Prefill Speed", + title: String(localized: "Prefill Speed"), value: BenchmarkResultsHelper.shared.formatSpeedStatisticsLine(prefillStats), - subtitle: "Tokens per second", + subtitle: String(localized: "Tokens per second"), color: .benchmarkGradientStart ) } else { PerformanceMetricView( icon: "speedometer", - title: "Prefill Speed", - value: "N/A", - subtitle: "Tokens per second", + title: String(localized: "Prefill Speed"), + value: String(localized: "N/A"), + subtitle: String(localized: "Tokens per second"), color: .benchmarkGradientStart ) } @@ -130,17 +130,17 @@ struct ResultsCard: View { if let decodeStats = statistics.decodeStats { PerformanceMetricView( icon: "gauge", - title: "Decode Speed", + title: String(localized: "Decode Speed"), value: BenchmarkResultsHelper.shared.formatSpeedStatisticsLine(decodeStats), - subtitle: "Generation rate", + subtitle: String(localized: "Generation rate"), color: .benchmarkGradientEnd ) } else { PerformanceMetricView( icon: "gauge", - title: "Decode Speed", - value: "N/A", - subtitle: "Generation rate", + title: String(localized: "Decode Speed"), + value: String(localized: "N/A"), + subtitle: String(localized: "Generation rate"), color: .benchmarkGradientEnd ) } @@ -155,17 +155,17 @@ struct ResultsCard: View { PerformanceMetricView( icon: "memorychip", - title: "Memory Usage", + title: String(localized: "Memory Usage"), value: memoryInfo.valueText, - subtitle: "Peak memory", + subtitle: String(localized: "Peak memory"), color: .benchmarkWarning ) PerformanceMetricView( icon: "clock", - title: "Total Tokens", + title: String(localized: "Total Tokens"), value: "\(statistics.totalTokensProcessed)", - subtitle: "Complete duration", + subtitle: String(localized: "Complete duration"), color: .benchmarkSuccess ) } @@ -176,9 +176,9 @@ struct ResultsCard: View { return VStack(alignment: .leading, spacing: 12) { VStack(spacing: 8) { HStack { - Text("Completed") - .font(.caption) - .foregroundColor(.benchmarkSecondary) + Text(String(localized: "Completed")) + .font(.caption) + .foregroundColor(.benchmarkSecondary) Spacer() Text(results.timestamp) .font(.caption) @@ -186,9 +186,9 @@ struct ResultsCard: View { } HStack { - Text("Powered By MNN") - .font(.caption) - .foregroundColor(.benchmarkSecondary) + Text(String(localized: "Powered By MNN")) + .font(.caption) + .foregroundColor(.benchmarkSecondary) Spacer() Text(verbatim: "https://github.com/alibaba/MNN") .font(.caption) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift index 251be813..ffbd1e4e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/StatusCard.swift @@ -31,7 +31,7 @@ struct StatusCard: View { } VStack(alignment: .leading, spacing: 4) { - Text("Status Update") + Text(String(localized: "Status Update")) .font(.subheadline) .fontWeight(.semibold) .foregroundColor(.primary) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift index 3b69d115..769b4f61 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkView.swift @@ -51,16 +51,16 @@ struct BenchmarkView: View { .padding(.vertical, 16) } } - .alert("Stop Benchmark", isPresented: $viewModel.showStopConfirmation) { - Button("Yes", role: .destructive) { + .alert(String(localized: "Stop Benchmark"), isPresented: $viewModel.showStopConfirmation) { + Button(String(localized: "Yes"), role: .destructive) { viewModel.onStopBenchmarkTapped() } - Button("No", role: .cancel) { } + Button(String(localized: "No"), role: .cancel) { } } message: { - Text("Are you sure you want to stop the benchmark test?") + Text(String(localized: "Are you sure you want to stop the benchmark test?")) } - .alert("Error", isPresented: $viewModel.showError) { - Button("OK") { + .alert(String(localized: "Error"), isPresented: $viewModel.showError) { + Button(String(localized: "OK")) { viewModel.dismissError() } } message: { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/LocalModelList/Views/LocalModelListView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/LocalModelList/Views/LocalModelListView.swift index e703b2fc..3f56c62c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/LocalModelList/Views/LocalModelListView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/LocalModelList/Views/LocalModelListView.swift @@ -42,7 +42,7 @@ struct LocalModelListView: View { } } .listStyle(.plain) - .searchable(text: $localSearchText, prompt: "搜索本地模型...") + .searchable(text: $localSearchText, prompt: "搜索模型...") .refreshable { await viewModel.fetchModels() } From aa7913c7bd1f38a9a64e4bc7c73acae01fba7d26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 3 Sep 2025 16:44:51 +0800 Subject: [PATCH 17/25] [feat] benchmark total time --- .../Helpers/BenchmarkResultsHelper.swift | 9 ++++++--- .../Benchmark/Models/BenchmarkResults.swift | 4 +++- .../Benchmark/Models/BenchmarkStatistics.swift | 4 +++- .../ViewModels/BenchmarkViewModel.swift | 17 +++++++++++++++-- .../Views/BenchmarkSubViews/ResultsCard.swift | 10 +++++----- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift index c54a9131..7e8352d6 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Helpers/BenchmarkResultsHelper.swift @@ -19,9 +19,11 @@ class BenchmarkResultsHelper { // MARK: - Results Processing & Statistics /// Processes test results to generate comprehensive benchmark statistics - /// - Parameter testResults: Array of completed test instances + /// - Parameters: + /// - testResults: Array of completed test instances + /// - totalTimeSeconds: Total benchmark runtime from start to completion /// - Returns: Processed statistics including speed metrics and configuration details - func processTestResults(_ testResults: [TestInstance]) -> BenchmarkStatistics { + func processTestResults(_ testResults: [TestInstance], totalTimeSeconds: Float = 0.0) -> BenchmarkStatistics { guard !testResults.isEmpty else { return BenchmarkStatistics.empty } @@ -65,7 +67,8 @@ class BenchmarkResultsHelper { prefillStats: prefillStats, decodeStats: decodeStats, totalTokensProcessed: totalTokensProcessed, - totalTests: testResults.count + totalTests: testResults.count, + totalTimeSeconds: Double(totalTimeSeconds) ) } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift index 3f57e5a1..6a02df35 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkResults.swift @@ -14,11 +14,13 @@ struct BenchmarkResults { let maxMemoryKb: Int64 let testResults: [TestInstance] let timestamp: String + let totalTimeSeconds: Float - init(modelDisplayName: String, maxMemoryKb: Int64, testResults: [TestInstance], timestamp: String) { + init(modelDisplayName: String, maxMemoryKb: Int64, testResults: [TestInstance], timestamp: String, totalTimeSeconds: Float = 0.0) { self.modelDisplayName = modelDisplayName self.maxMemoryKb = maxMemoryKb self.testResults = testResults self.timestamp = timestamp + self.totalTimeSeconds = totalTimeSeconds } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift index 89397f8c..7e4de8dc 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Models/BenchmarkStatistics.swift @@ -15,12 +15,14 @@ struct BenchmarkStatistics { let decodeStats: SpeedStatistics? let totalTokensProcessed: Int let totalTests: Int + let totalTimeSeconds: Double static let empty = BenchmarkStatistics( configText: "", prefillStats: nil, decodeStats: nil, totalTokensProcessed: 0, - totalTests: 0 + totalTests: 0, + totalTimeSeconds: 0.0 ) } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift index 53f5a584..dace3e6c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/ViewModels/BenchmarkViewModel.swift @@ -43,6 +43,10 @@ class BenchmarkViewModel: ObservableObject { // Model list manager for getting local models private let modelListManager = ModelListManager.shared + // Track total benchmark runtime from start to completion + private var totalBenchmarkTimeSeconds: Float = 0.0 + private var benchmarkStartTime: Date? + // MARK: - Initialization & Setup init() { @@ -78,7 +82,7 @@ class BenchmarkViewModel: ObservableObject { // Filter only downloaded models that are available locally availableModels = allModels.filter { model in - model.isDownloaded && model.localPath != "" + model.isDownloaded && model.localPath != "" && !model.modelName.lowercased().contains("omni") } print("BenchmarkViewModel: Loaded \(availableModels.count) available local models") @@ -218,6 +222,8 @@ class BenchmarkViewModel: ObservableObject { startButtonText = String(localized: "Stop Test") showProgressBar = true showResults = false + totalBenchmarkTimeSeconds = 0.0 + benchmarkStartTime = Date() updateStatus("Initializing benchmark...") } @@ -229,6 +235,7 @@ class BenchmarkViewModel: ObservableObject { showProgressBar = false hideStatus() showResults = false + benchmarkStartTime = nil cleanupBenchmarkResources() } @@ -309,6 +316,11 @@ extension BenchmarkViewModel: BenchmarkCallback { let formattedProgress = formatProgressMessage(progress) currentProgress = formattedProgress updateStatus(formattedProgress.statusMessage) + + // Calculate the total runtime from benchmark start to current point + if let startTime = benchmarkStartTime { + totalBenchmarkTimeSeconds = Float(Date().timeIntervalSince(startTime)) + } } /// Handles benchmark completion with results processing @@ -322,7 +334,8 @@ extension BenchmarkViewModel: BenchmarkCallback { modelDisplayName: model.modelName, maxMemoryKb: MemoryMonitor.shared.getMaxMemoryKb(), testResults: [result.testInstance], - timestamp: DateFormatter.benchmarkTimestamp.string(from: Date()) + timestamp: DateFormatter.benchmarkTimestamp.string(from: Date()), + totalTimeSeconds: totalBenchmarkTimeSeconds ) benchmarkResults = results diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift index fe6c0347..538b510c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/Benchmark/Views/BenchmarkSubViews/ResultsCard.swift @@ -34,7 +34,7 @@ struct ResultsCard: View { private var infoHeader: some View { - let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults) + let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults, totalTimeSeconds: results.totalTimeSeconds) return VStack(alignment: .leading, spacing: 8) { Text(results.modelDisplayName) @@ -105,7 +105,7 @@ struct ResultsCard: View { private var performanceMetrics: some View { - let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults) + let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults, totalTimeSeconds: results.totalTimeSeconds) return VStack(spacing: 16) { HStack(spacing: 12) { @@ -163,8 +163,8 @@ struct ResultsCard: View { PerformanceMetricView( icon: "clock", - title: String(localized: "Total Tokens"), - value: "\(statistics.totalTokensProcessed)", + title: String(localized: "Total Time"), + value: String(format: "%.2f s", statistics.totalTimeSeconds), subtitle: String(localized: "Complete duration"), color: .benchmarkSuccess ) @@ -238,7 +238,7 @@ struct ResultsCard: View { /// Formats benchmark results into shareable text format with performance metrics and hashtags private func formatResultsForSharing() -> String { - let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults) + let statistics = BenchmarkResultsHelper.shared.processTestResults(results.testResults, totalTimeSeconds: results.totalTimeSeconds) let deviceInfo = BenchmarkResultsHelper.shared.getDeviceInfo() var shareText = """ From 16c8b585501980ed5580fedbbcdd9420e0f3a9b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 3 Sep 2025 17:23:52 +0800 Subject: [PATCH 18/25] [feat] add model loading overlay --- .../Chat/ViewModels/LLMChatViewModel.swift | 6 +- .../MNNLLMiOS/Chat/Views/LLMChatView.swift | 191 ++++++++++-------- .../MNNLLMiOS/Localizable.xcstrings | 13 +- 3 files changed, 120 insertions(+), 90 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift index aa4b6607..5cbef04e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift @@ -77,7 +77,6 @@ final class LLMChatViewModel: ObservableObject { // Check if model supports thinking mode self.supportsThinkingMode = ModelUtils.isSupportThinkingSwitch(modelInfo.tags, modelName: modelInfo.modelName) - } deinit { @@ -104,6 +103,7 @@ final class LLMChatViewModel: ObservableObject { func setupLLM(modelPath: String) { Task { @MainActor in + self.isModelLoaded = false self.send(draft: DraftMessage( text: NSLocalizedString("ModelLoadingText", comment: ""), thinkText: "", @@ -118,16 +118,16 @@ final class LLMChatViewModel: ObservableObject { diffusion = DiffusionSession(modelPath: modelPath, completion: { [weak self] success in Task { @MainActor in print("Diffusion Model \(success)") - self?.isModelLoaded = success self?.sendModelLoadStatus(success: success) + self?.isModelLoaded = success } }) } else { llm = LLMInferenceEngineWrapper(modelPath: modelPath) { [weak self] success in Task { @MainActor in - self?.isModelLoaded = success self?.sendModelLoadStatus(success: success) self?.processHistoryMessages() + self?.isModelLoaded = success // Configure thinking mode after model is loaded if success { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift index 8f8abcd6..aa62305c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift @@ -31,104 +31,123 @@ struct LLMChatView: View { } var body: some View { - ChatView(messages: viewModel.messages, chatType: .conversation) { draft in - viewModel.sendToLLM(draft: draft) - } - .setStreamingMessageProvider { - viewModel.currentStreamingMessageId - } - .setAvailableInput( - self.title.lowercased().contains("omni") ? .full: - self.title.lowercased().contains("vl") ? .textAndMedia : - self.title.lowercased().contains("audio") ? .textAndAudio : - (self.title.isEmpty ? .textOnly : .textOnly) - ) - .messageUseMarkdown(true) - .setRecorderSettings(recorderSettings) - .setThinkingMode( - supportsThinkingMode: viewModel.supportsThinkingMode, - isEnabled: viewModel.isThinkingModeEnabled, - onToggle: { - viewModel.toggleThinkingMode() + ZStack { + ChatView(messages: viewModel.messages, chatType: .conversation) { draft in + viewModel.sendToLLM(draft: draft) } - ) - .chatTheme( - ChatTheme( - colors: .init( - messageMyBG: .customBlue.opacity(0.2), - messageFriendBG: .clear - ), - images: .init( - attach: Image(systemName: "photo"), - attachCamera: Image("attachCamera", bundle: .current) + .setStreamingMessageProvider { + viewModel.currentStreamingMessageId + } + .setAvailableInput( + self.title.lowercased().contains("omni") ? .full: + self.title.lowercased().contains("vl") ? .textAndMedia : + self.title.lowercased().contains("audio") ? .textAndAudio : + (self.title.isEmpty ? .textOnly : .textOnly) + ) + .messageUseMarkdown(true) + .setRecorderSettings(recorderSettings) + .setThinkingMode( + supportsThinkingMode: viewModel.supportsThinkingMode, + isEnabled: viewModel.isThinkingModeEnabled, + onToggle: { + viewModel.toggleThinkingMode() + } + ) + .chatTheme( + ChatTheme( + colors: .init( + messageMyBG: .customBlue.opacity(0.2), + messageFriendBG: .clear + ), + images: .init( + attach: Image(systemName: "photo"), + attachCamera: Image("attachCamera", bundle: .current) + ) ) ) - ) - .mediaPickerTheme( - main: .init( - text: .white, - albumSelectionBackground: .customPickerBg, - fullscreenPhotoBackground: .customPickerBg, - cameraBackground: .black, - cameraSelectionBackground: .black - ), - selection: .init( - emptyTint: .white, - emptyBackground: .black.opacity(0.25), - selectedTint: .customBlue, - fullscreenTint: .white + .mediaPickerTheme( + main: .init( + text: .white, + albumSelectionBackground: .customPickerBg, + fullscreenPhotoBackground: .customPickerBg, + cameraBackground: .black, + cameraSelectionBackground: .black + ), + selection: .init( + emptyTint: .white, + emptyBackground: .black.opacity(0.25), + selectedTint: .customBlue, + fullscreenTint: .white + ) ) - ) - .navigationBarTitle("") - .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden() - .disabled(viewModel.chatInputUnavilable) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button { - presentationMode.wrappedValue.dismiss() - } label: { - Image("backArrow", bundle: .current) + .navigationBarTitle("") + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + .disabled(viewModel.chatInputUnavilable) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button { + presentationMode.wrappedValue.dismiss() + } label: { + Image("backArrow", bundle: .current) + } } - } - ToolbarItem(placement: .principal) { - HStack { - VStack(alignment: .leading, spacing: 0) { - Text(title) - .fontWeight(.semibold) - .font(.headline) - .foregroundColor(.black) - - Text(viewModel.chatStatus) - .font(.footnote) - .foregroundColor(Color(hex: "AFB3B8")) + ToolbarItem(placement: .principal) { + HStack { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .fontWeight(.semibold) + .font(.headline) + .foregroundColor(.black) + + Text(viewModel.chatStatus) + .font(.footnote) + .foregroundColor(Color(hex: "AFB3B8")) + } + Spacer() } - Spacer() + .padding(.leading, 10) } - .padding(.leading, 10) - } - ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 8) { - // Settings Button - Button(action: { showSettings.toggle() }) { - Image(systemName: "gear") - } - .sheet(isPresented: $showSettings) { - ModelSettingsView(showSettings: $showSettings, viewModel: viewModel) + ToolbarItem(placement: .navigationBarTrailing) { + HStack(spacing: 8) { + // Settings Button + Button(action: { showSettings.toggle() }) { + Image(systemName: "gear") + } + .sheet(isPresented: $showSettings) { + ModelSettingsView(showSettings: $showSettings, viewModel: viewModel) + } } } } - } - - .onAppear { - viewModel.onStart() - } - .onDisappear(perform: viewModel.onStop) - .onReceive(NotificationCenter.default.publisher(for: .dismissKeyboard)) { _ in - // 隐藏键盘 - UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + .onAppear { + viewModel.onStart() + } + .onDisappear(perform: viewModel.onStop) + .onReceive(NotificationCenter.default.publisher(for: .dismissKeyboard)) { _ in + // Hidden keyboard + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + // Loading overlay + if !viewModel.isModelLoaded { + Color.black.opacity(0.4) + .ignoresSafeArea() + .overlay( + VStack(spacing: 20) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .scaleEffect(1.5) + + Text(NSLocalizedString("Model is loading...", comment: "")) + .font(.system(size: 15, weight: .regular)) + .foregroundColor(.white) + .font(.headline) + } + ) + } } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings b/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings index 62559b53..e8b1fa9e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings @@ -536,6 +536,16 @@ } } }, + "Model is loading..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模型加载中,请稍后……" + } + } + } + }, "Model Market" : { "comment" : "模型市场标签", "localizations" : { @@ -1331,7 +1341,7 @@ } }, "Total Time" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1342,6 +1352,7 @@ } }, "Total Tokens" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { From cc5ebaf797f5edf255f107391b3e3f993bffe609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Wed, 3 Sep 2025 19:25:49 +0800 Subject: [PATCH 19/25] [update] update last model usage --- .../Chat/ViewModels/LLMChatViewModel.swift | 18 ++++++++++++ .../ModelList/Models/ModelListViewModel.swift | 29 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift index 5cbef04e..c95fb0cd 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift @@ -196,6 +196,9 @@ final class LLMChatViewModel: ObservableObject { NotificationCenter.default.post(name: .dismissKeyboard, object: nil) self.send(draft: draft, userType: .user) + + recordModelUsage() + if isModelLoaded { if modelInfo.modelName.lowercased().contains("diffusion") { self.getDiffusionResponse(draft: draft) @@ -390,9 +393,14 @@ final class LLMChatViewModel: ObservableObject { interactor.connect() self.setupLLM(modelPath: self.modelInfo.localPath) + + recordModelUsage() } func onStop() { + + recordModelUsage() + ChatHistoryManager.shared.saveChat( historyId: historyId, modelInfo: modelInfo, @@ -419,4 +427,14 @@ final class LLMChatViewModel: ObservableObject { .sink { _ in } .store(in: &subscriptions) } + + private func recordModelUsage() { + ModelStorageManager.shared.updateLastUsed(for: modelInfo.modelName) + + NotificationCenter.default.post( + name: .modelUsageUpdated, + object: nil, + userInfo: ["modelName": modelInfo.modelName] + ) + } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift index 6f644535..5622ed8e 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Models/ModelListViewModel.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import Combine class ModelListViewModel: ObservableObject { // MARK: - Published Properties @@ -23,6 +24,7 @@ class ModelListViewModel: ObservableObject { // MARK: - Private Properties private let modelClient = ModelClient.shared private let pinnedModelKey = "com.mnnllm.pinnedModelIds" + private var cancellables = Set() // MARK: - Model Data Access @@ -49,6 +51,17 @@ class ModelListViewModel: ObservableObject { Task { @MainActor in await fetchModels() } + + NotificationCenter.default + .publisher(for: .modelUsageUpdated) + .sink { [weak self] notification in + if let modelName = notification.userInfo?["modelName"] as? String { + Task { @MainActor in + self?.updateModelLastUsed(modelName: modelName) + } + } + } + .store(in: &cancellables) } // MARK: - Model Data Management @@ -490,4 +503,20 @@ class ModelListViewModel: ObservableObject { } } } + + @MainActor + private func updateModelLastUsed(modelName: String) { + if let index = models.firstIndex(where: { $0.modelName == modelName }) { + if let lastUsed = ModelStorageManager.shared.getLastUsed(for: modelName) { + models[index].lastUsedAt = lastUsed + sortModels(fetchedModels: &models) + } + } + } +} + +// MARK: - Notification Names + +extension Notification.Name { + static let modelUsageUpdated = Notification.Name("modelUsageUpdated") } From 4f842f76c529e3960c00dcea56fe3c5321a70b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Thu, 4 Sep 2025 19:47:26 +0800 Subject: [PATCH 20/25] [update] limit take photo and select one image --- .../MNNLLMChat/MNNLLMiOS/Chat/Models/LLMChatData.swift | 1 - apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMState.swift | 1 - .../MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift | 1 - .../iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift | 6 ++++++ .../ChatHistory/Services/ChatHistoryDatabase.swift | 8 ++++---- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMChatData.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMChatData.swift index 3b26b7a9..9b4e9d2b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMChatData.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMChatData.swift @@ -8,7 +8,6 @@ import UIKit import ExyteChat -import ExyteMediaPicker final class LLMChatData { var assistant: LLMChatUser diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMState.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMState.swift index d1800e4d..7098f5f2 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMState.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Models/LLMState.swift @@ -6,7 +6,6 @@ // import ExyteChat -import ExyteMediaPicker actor LLMState { private var isProcessing: Bool = false diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift index c95fb0cd..ed5a842b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift @@ -9,7 +9,6 @@ import SwiftUI import AVFoundation import ExyteChat -import ExyteMediaPicker final class LLMChatViewModel: ObservableObject { diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift index aa62305c..c180ae72 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/Views/LLMChatView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import ExyteChat +import ExyteMediaPicker import AVFoundation struct LLMChatView: View { @@ -53,6 +54,11 @@ struct LLMChatView: View { viewModel.toggleThinkingMode() } ) + .setMediaPickerSelectionParameters( + MediaPickerParameters(mediaType: .photo, + selectionLimit: 1, + showFullscreenPreview: false) + ) .chatTheme( ChatTheme( colors: .init( diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift index be44b3d2..6612a78c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift @@ -174,19 +174,19 @@ class ChatHistoryDatabase { do { let modelInfoData = modelInfoString.data(using: .utf8)! modelInfoObj = try JSONDecoder().decode(ModelInfo.self, from: modelInfoData) - print("Successfully decoded ModelInfo from JSON for history: \(history[id])") + // print("Successfully decoded ModelInfo from JSON for history: \(history[id])") } catch { - print("Failed to decode ModelInfo from JSON, using fallback: \(error)") + // print("Failed to decode ModelInfo from JSON, using fallback: \(error)") modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true) } } else { // For backward compatibility - print("No modelInfo data found, using fallback for history: \(history[id])") + // print("No modelInfo data found, using fallback for history: \(history[id])") modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true) } } catch { // For backward compatibility - print("ModelInfo column not found, using fallback for history: \(history[id])") + // print("ModelInfo column not found, using fallback for history: \(history[id])") modelInfoObj = ModelInfo(modelId: history[modelId], isDownloaded: true) } From 9c1d8e99a763be21e71710ce51efdbdd0a514b02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 5 Sep 2025 11:21:36 +0800 Subject: [PATCH 21/25] [fix] filters cause blank screen Auto-scroll to top when filters change to avoid blank screen when data shrinks --- .../ModelList/Views/ModelListView.swift | 52 +++++++++++++------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift index 91bd7c90..e543c8fe 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift @@ -18,25 +18,45 @@ struct ModelListView: View { @State private var showFilterMenu = false var body: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Section { - modelListSection - } header: { - toolbarSection + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + + Color.clear.frame(height: 0).id("TOP") + + Section { + modelListSection + } header: { + toolbarSection + } } } - } - .searchable(text: $searchText, prompt: "Search models...") - .refreshable { - await viewModel.fetchModels() - } - .alert("Error", isPresented: $viewModel.showError) { - Button("OK") { - viewModel.dismissError() + .searchable(text: $searchText, prompt: "Search models...") + .refreshable { + await viewModel.fetchModels() + } + .alert("Error", isPresented: $viewModel.showError) { + Button("OK") { + viewModel.dismissError() + } + } message: { + Text(viewModel.errorMessage) + } + // Auto-scroll to top when filters change to avoid blank screen when data shrinks + .onChange(of: selectedTags) { old, new in + withAnimation { proxy.scrollTo("TOP", anchor: .top) } + } + .onChange(of: selectedCategories) { old, new in + withAnimation { proxy.scrollTo("TOP", anchor: .top) } + } + .onChange(of: selectedVendors) { old, new in + withAnimation { proxy.scrollTo("TOP", anchor: .top) } + } + .onChange(of: showFilterMenu) { old, new in + if old != new { + withAnimation { proxy.scrollTo("TOP", anchor: .top) } + } } - } message: { - Text(viewModel.errorMessage) } } From 081de89e86712cf3e9ecf369808e4b38bb8201b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 5 Sep 2025 15:22:05 +0800 Subject: [PATCH 22/25] [update] code safety, style and package --- .../MNNLLMiOS.xcodeproj/project.pbxproj | 60 +++++++++++-------- .../Chat/ViewModels/LLMChatViewModel.swift | 41 +++++++------ .../Services/ChatHistoryDatabase.swift | 4 +- .../MNNLLMiOS/Localizable.xcstrings | 11 +--- .../MainTab/ModelList/Views/HelpView.swift | 2 +- .../ModelList/Views/ModelListView.swift | 11 ++-- .../Service/ModelDownloader/ModelClient.swift | 2 +- .../ModelDownloadManager.swift | 2 +- .../ModelScopeDownloadManager.swift | 2 +- .../Service/Util/AssetExtractor.swift | 7 ++- .../MNNLLMiOS/Service/Util/Util.swift | 16 ++--- 11 files changed, 81 insertions(+), 77 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.pbxproj b/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.pbxproj index 1708dca5..0045b227 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.pbxproj +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.pbxproj @@ -8,9 +8,10 @@ /* Begin PBXBuildFile section */ 3E301C692D5C84730045E5E1 /* MNN.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E301C682D5C84730045E5E1 /* MNN.framework */; }; + 3E6301D42E6A8A470004EC63 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 3E6301D32E6A8A470004EC63 /* ExyteChat */; }; 3E94DFF12D37DBA900BE39A7 /* SQLite in Frameworks */ = {isa = PBXBuildFile; productRef = 3E94DFF02D37DBA900BE39A7 /* SQLite */; }; - 3EC8DF852E4DCF5600861131 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 3EC8DF842E4DCF5600861131 /* ExyteChat */; }; 3EC8DF872E4DCF5800861131 /* Transformers in Frameworks */ = {isa = PBXBuildFile; productRef = 3EC8DF862E4DCF5800861131 /* Transformers */; }; + 3EDA70222E605BE700B17E48 /* ExyteChat in Frameworks */ = {isa = PBXBuildFile; productRef = 3EDA70212E605BE700B17E48 /* ExyteChat */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -84,10 +85,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3EDA70222E605BE700B17E48 /* ExyteChat in Frameworks */, 3E301C692D5C84730045E5E1 /* MNN.framework in Frameworks */, + 3E6301D42E6A8A470004EC63 /* ExyteChat in Frameworks */, 3E94DFF12D37DBA900BE39A7 /* SQLite in Frameworks */, 3EC8DF872E4DCF5800861131 /* Transformers in Frameworks */, - 3EC8DF852E4DCF5600861131 /* ExyteChat in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -158,8 +160,9 @@ name = MNNLLMiOS; packageProductDependencies = ( 3E94DFF02D37DBA900BE39A7 /* SQLite */, - 3EC8DF842E4DCF5600861131 /* ExyteChat */, 3EC8DF862E4DCF5800861131 /* Transformers */, + 3EDA70212E605BE700B17E48 /* ExyteChat */, + 3E6301D32E6A8A470004EC63 /* ExyteChat */, ); productName = MNNLLMiOS; productReference = 3E8591FE2D1D45070067B46F /* MNNLLMiOS.app */; @@ -246,8 +249,8 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 3E94DF8A2D37D8FF00BE39A7 /* XCRemoteSwiftPackageReference "SQLite" */, - 3EC8DF822E4DCDF500861131 /* XCRemoteSwiftPackageReference "Chat" */, 3EC8DF832E4DCF4A00861131 /* XCRemoteSwiftPackageReference "swift-transformers" */, + 3E6301D22E6A8A470004EC63 /* XCRemoteSwiftPackageReference "Chat" */, ); preferredProjectObjectVersion = 77; productRefGroup = 3E8591FF2D1D45070067B46F /* Products */; @@ -357,7 +360,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -451,6 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = MNNLLMiOS/MNNLLMiOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"MNNLLMiOS/Preview Content\""; @@ -465,7 +469,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MNNLLMiOS/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "MNN Chat"; - INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "We need access your camera to capture image or video"; INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "This app needs to access your documents to save downloaded models"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "This app needs to access your local network to download models"; @@ -479,8 +483,7 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; @@ -488,6 +491,7 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.taobao.mnnchat; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -506,6 +510,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = MNNLLMiOS/MNNLLMiOS.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"MNNLLMiOS/Preview Content\""; @@ -520,7 +525,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = MNNLLMiOS/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "MNN Chat"; - INFOPLIST_KEY_LSApplicationCategoryType = ""; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_NSCameraUsageDescription = "We need access your camera to capture image or video"; INFOPLIST_KEY_NSDocumentsFolderUsageDescription = "This app needs to access your documents to save downloaded models"; INFOPLIST_KEY_NSLocalNetworkUsageDescription = "This app needs to access your local network to download models"; @@ -534,15 +539,15 @@ "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 17; LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 14; - MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.taobao.mnnchat; + MARKETING_VERSION = 1.0.1; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; @@ -562,6 +567,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 6QW92DF7RL; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSCameraUsageDescription = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.2; MACOSX_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; @@ -686,6 +692,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 3E6301D22E6A8A470004EC63 /* XCRemoteSwiftPackageReference "Chat" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Yogayu/Chat.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; 3E94DF8A2D37D8FF00BE39A7 /* XCRemoteSwiftPackageReference "SQLite" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/stephencelis/SQLite.swift"; @@ -694,14 +708,6 @@ minimumVersion = 0.15.3; }; }; - 3EC8DF822E4DCDF500861131 /* XCRemoteSwiftPackageReference "Chat" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/Yogayu/Chat.git"; - requirement = { - branch = main; - kind = branch; - }; - }; 3EC8DF832E4DCF4A00861131 /* XCRemoteSwiftPackageReference "swift-transformers" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/huggingface/swift-transformers/"; @@ -713,21 +719,25 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 3E6301D32E6A8A470004EC63 /* ExyteChat */ = { + isa = XCSwiftPackageProductDependency; + package = 3E6301D22E6A8A470004EC63 /* XCRemoteSwiftPackageReference "Chat" */; + productName = ExyteChat; + }; 3E94DFF02D37DBA900BE39A7 /* SQLite */ = { isa = XCSwiftPackageProductDependency; package = 3E94DF8A2D37D8FF00BE39A7 /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; - 3EC8DF842E4DCF5600861131 /* ExyteChat */ = { - isa = XCSwiftPackageProductDependency; - package = 3EC8DF822E4DCDF500861131 /* XCRemoteSwiftPackageReference "Chat" */; - productName = ExyteChat; - }; 3EC8DF862E4DCF5800861131 /* Transformers */ = { isa = XCSwiftPackageProductDependency; package = 3EC8DF832E4DCF4A00861131 /* XCRemoteSwiftPackageReference "swift-transformers" */; productName = Transformers; }; + 3EDA70212E605BE700B17E48 /* ExyteChat */ = { + isa = XCSwiftPackageProductDependency; + productName = ExyteChat; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 3E8591F62D1D45070067B46F /* Project object */; diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift index ed5a842b..e3446a9c 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Chat/ViewModels/LLMChatViewModel.swift @@ -47,12 +47,12 @@ final class LLMChatViewModel: ObservableObject { } var chatCover: URL? { - interactor.otherSenders.count == 1 ? interactor.otherSenders.first!.avatar : nil + interactor.otherSenders.count == 1 ? interactor.otherSenders.first?.avatar : nil } - + private let interactor: LLMChatInteractor private var subscriptions = Set() - + var modelInfo: ModelInfo var history: ChatHistory? private var historyId: String @@ -64,6 +64,7 @@ final class LLMChatViewModel: ObservableObject { } init(modelInfo: ModelInfo, history: ChatHistory? = nil) { + print("yxy:: LLMChat View Model init") self.modelInfo = modelInfo self.history = history self.historyId = history?.id ?? UUID().uuidString @@ -112,7 +113,7 @@ final class LLMChatViewModel: ObservableObject { createdAt: Date() ), userType: .system) } - + if modelInfo.modelName.lowercased().contains("diffusion") { diffusion = DiffusionSession(modelPath: modelPath, completion: { [weak self] success in Task { @MainActor in @@ -154,7 +155,7 @@ final class LLMChatViewModel: ObservableObject { let modelLoadSuccessText = NSLocalizedString("ModelLoadingSuccessText", comment: "") let modelLoadFailText = NSLocalizedString("ModelLoadingFailText", comment: "") let loadResult = success ? modelLoadSuccessText : modelLoadFailText - + self.send(draft: DraftMessage( text: loadResult, thinkText: "", @@ -216,7 +217,7 @@ final class LLMChatViewModel: ObservableObject { Task { let tempImagePath = FileOperationManager.shared.generateTempImagePath().path - + var lastProcess:Int32 = 0 self.send(draft: DraftMessage(text: "Start Generating Image...", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .assistant) @@ -225,16 +226,15 @@ final class LLMChatViewModel: ObservableObject { let userIterations = self.modelConfigManager.readIterations() let userSeed = self.modelConfigManager.readSeed() - // 使用用户设置的参数调用新方法 - diffusion?.run(withPrompt: draft.text, - imagePath: tempImagePath, - iterations: Int32(userIterations), - seed: Int32(userSeed), - progressCallback: { [weak self] progress in + diffusion?.run(withPrompt: draft.text, + imagePath: tempImagePath, + iterations: Int32(userIterations), + seed: Int32(userSeed), + progressCallback: { [weak self] progress in guard let self = self else { return } if progress == 100 { self.send(draft: DraftMessage(text: "Image generated successfully!", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system) - self.interactor.sendImage(imageURL: URL(string: "file://" + tempImagePath)!) + self.interactor.sendImage(imageURL: URL(fileURLWithPath: tempImagePath)) } else if ((progress - lastProcess) > 20) { lastProcess = progress self.send(draft: DraftMessage(text: "Generating Image \(progress)%", thinkText: "", medias: [], recording: nil, replyMessage: nil, createdAt: Date()), userType: .system) @@ -246,7 +246,7 @@ final class LLMChatViewModel: ObservableObject { func getLLMRespsonse(draft: DraftMessage) { Task { await llmState.setProcessing(true) - await MainActor.run { + await MainActor.run { self.isProcessing = true let emptyMessage = DraftMessage( text: "", @@ -270,7 +270,7 @@ final class LLMChatViewModel: ObservableObject { guard media.type == .image, let url = await media.getURL() else { continue } - + let fileName = url.lastPathComponent if let processedUrl = FileOperationManager.shared.processImageFile(from: url, fileName: fileName) { @@ -279,9 +279,9 @@ final class LLMChatViewModel: ObservableObject { } if let audio = draft.recording, let path = audio.url { -// if let wavFile = await convertACCToWAV(accFileUrl: path) { + // if let wavFile = await convertACCToWAV(accFileUrl: path) { content = "" + content -// } + // } } let convertedContent = self.convertDeepSeekMutliChat(content: content) @@ -319,7 +319,7 @@ final class LLMChatViewModel: ObservableObject { return } - Task { + Task { await UIUpdateOptimizer.shared.addUpdate(output) { [weak self] output in guard let self = self else { return } self.send(draft: DraftMessage( @@ -395,7 +395,7 @@ final class LLMChatViewModel: ObservableObject { recordModelUsage() } - + func onStop() { recordModelUsage() @@ -406,7 +406,6 @@ final class LLMChatViewModel: ObservableObject { messages: messages ) - subscriptions.removeAll() interactor.disconnect() @@ -420,7 +419,7 @@ final class LLMChatViewModel: ObservableObject { FileOperationManager.shared.cleanModelTempFolder(modelPath: modelInfo.localPath) } } - + func loadMoreMessage(before message: Message) { interactor.loadNextPage() .sink { _ in } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift index 6612a78c..991f111f 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/ChatHistory/Services/ChatHistoryDatabase.swift @@ -32,7 +32,9 @@ class ChatHistoryDatabase { private let updatedAt: Column private init() throws { - let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first! + guard let path = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true).first else { + throw NSError(domain: "ChatHistoryDatabase", code: -1, userInfo: [NSLocalizedDescriptionKey: "Documents directory not found"]) + } db = try Connection("\(path)/chatHistory.sqlite3") chatHistories = Table("chatHistories") diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings b/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings index e8b1fa9e..31ff7307 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Localizable.xcstrings @@ -537,14 +537,7 @@ } }, "Model is loading..." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "模型加载中,请稍后……" - } - } - } + }, "Model Market" : { "comment" : "模型市场标签", @@ -1450,7 +1443,7 @@ "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "搜索模型..." + "value" : "搜索模型" } } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/HelpView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/HelpView.swift index 179097ff..3ca28aa5 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/HelpView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/HelpView.swift @@ -9,7 +9,7 @@ import SwiftUI struct HelpView: View { var body: some View { - WebView(url: URL(string: "https://github.com/alibaba/MNN")!) // ?tab=readme-ov-file#intro + WebView(url: URL(string: "https://github.com/alibaba/MNN") ?? URL(fileURLWithPath: "/")) // ?tab=readme-ov-file#intro .navigationTitle("Help") .navigationBarTitleDisplayMode(.inline) } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift index e543c8fe..65d14f1b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MainTab/ModelList/Views/ModelListView.swift @@ -16,13 +16,14 @@ struct ModelListView: View { @State private var selectedCategories: Set = [] @State private var selectedVendors: Set = [] @State private var showFilterMenu = false + private let topID = "topID" var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - Color.clear.frame(height: 0).id("TOP") + Color.clear.frame(height: 0).id(topID) Section { modelListSection @@ -44,17 +45,17 @@ struct ModelListView: View { } // Auto-scroll to top when filters change to avoid blank screen when data shrinks .onChange(of: selectedTags) { old, new in - withAnimation { proxy.scrollTo("TOP", anchor: .top) } + withAnimation { proxy.scrollTo(topID, anchor: .top) } } .onChange(of: selectedCategories) { old, new in - withAnimation { proxy.scrollTo("TOP", anchor: .top) } + withAnimation { proxy.scrollTo(topID, anchor: .top) } } .onChange(of: selectedVendors) { old, new in - withAnimation { proxy.scrollTo("TOP", anchor: .top) } + withAnimation { proxy.scrollTo(topID, anchor: .top) } } .onChange(of: showFilterMenu) { old, new in if old != new { - withAnimation { proxy.scrollTo("TOP", anchor: .top) } + withAnimation { proxy.scrollTo(topID, anchor: .top) } } } } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift index 89e6e024..469722d9 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelClient.swift @@ -253,7 +253,7 @@ class ModelClient { var lastUpdateTime = Date() var lastProgress: Double = 0.0 let progressUpdateInterval: TimeInterval = 0.1 // Limit update frequency to every 100ms - let progressThreshold: Double = 0.01 // Progress change threshold of 1% + let progressThreshold: Double = 0.001 // Progress change threshold of 0.1% try await mirrorHubApi.snapshot(from: repo, matching: modelFiles) { fileProgress in let currentProgress = fileProgress.fractionCompleted diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift index 888a02ef..ca4607de 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelDownloadManager.swift @@ -734,7 +734,7 @@ public actor ModelDownloadManager: ModelDownloadManagerProtocol { let newProgress = progress.progress let progressDiff = abs(newProgress - progress.lastReportedProgress) - if progressDiff >= 0.01 || newProgress >= 1.0 { + if progressDiff >= 0.001 || newProgress >= 1.0 { progress.lastReportedProgress = newProgress await updateProgress(newProgress) } diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift index c680cab1..c1ef169b 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/ModelDownloader/ModelScopeDownloadManager.swift @@ -196,7 +196,7 @@ public actor ModelScopeDownloadManager: ModelDownloadManagerProtocol { private func updateProgress(_ progress: Double, callback: @escaping (Double) -> Void) { // Only update UI progress if there's a significant change (>0.1%) let progressDiff = abs(progress - lastReportedProgress) - if progressDiff >= 0.01 || progress >= 1.0 { + if progressDiff >= 0.001 || progress >= 1.0 { lastReportedProgress = progress Task { @MainActor in callback(progress) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/AssetExtractor.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/AssetExtractor.swift index c8a8abce..5019abef 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/AssetExtractor.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/AssetExtractor.swift @@ -126,11 +126,14 @@ extension UIImage { } UIGraphicsBeginImageContext(self.size) - let context = UIGraphicsGetCurrentContext()! + guard let context = UIGraphicsGetCurrentContext(), let cgImage = self.cgImage else { + UIGraphicsEndImageContext() + return self + } context.translateBy(x: self.size.width / 2, y: self.size.height / 2) context.rotate(by: angle) context.scaleBy(x: 1.0, y: -1.0) - context.draw(self.cgImage!, in: CGRect(x: -self.size.width / 2, y: -self.size.height / 2, width: self.size.width, height: self.size.height)) + context.draw(cgImage, in: CGRect(x: -self.size.width / 2, y: -self.size.height / 2, width: self.size.width, height: self.size.height)) let rotatedImage = UIGraphicsGetImageFromCurrentImageContext() UIGraphicsEndImageContext() diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Util.swift b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Util.swift index bd9e7efe..e9af98a5 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Util.swift +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/Service/Util/Util.swift @@ -104,11 +104,9 @@ extension DraftMessage { .asyncMap { (media : Media) -> (Media, URL?, URL?) in (media, await media.getThumbnailURL(), await media.getURL()) } - .filter { (media: Media, thumb: URL?, full: URL?) -> Bool in - thumb != nil && full != nil - } - .map { media, thumb, full in - LLMChatImage(id: media.id.uuidString, thumbnail: thumb!, full: full!) + .compactMap { media, thumb, full in + guard let thumb, let full else { return nil } + return LLMChatImage(id: media.id.uuidString, thumbnail: thumb, full: full) } } @@ -118,11 +116,9 @@ extension DraftMessage { .asyncMap { (media : Media) -> (Media, URL?, URL?) in (media, await media.getThumbnailURL(), await media.getURL()) } - .filter { (media: Media, thumb: URL?, full: URL?) -> Bool in - thumb != nil && full != nil - } - .map { media, thumb, full in - LLMChatVideo(id: media.id.uuidString, thumbnail: thumb!, full: full!) + .compactMap { media, thumb, full in + guard let thumb, let full else { return nil } + return LLMChatVideo(id: media.id.uuidString, thumbnail: thumb, full: full) } } From 34980ded16b5fc324fbbdc33f7750912ec5e5404 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 5 Sep 2025 15:27:57 +0800 Subject: [PATCH 23/25] [update] chat version # Conflicts: # .gitignore --- .gitignore | 3 ++- .../xcshareddata/swiftpm/Package.resolved | 24 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 6da2bbc3..86d609f5 100644 --- a/.gitignore +++ b/.gitignore @@ -378,7 +378,8 @@ datasets/* source/backend/qnn/3rdParty/include project/android/.cxx pymnn/android/.cxx/ -pymnn/android/.cxx/abi_configuration_5u53tc49.json +pymnn/android/.cxx/abi_configuration_5u53tc49.jsonz apps/iOS/MNNLLMChat/MNNLLMiOS/LocalModel/Qwen3-0.6B-MNN apps/iOS/MNNLLMChat/Chat/ apps/iOS/MNNLLMChat/swift-transformers/ +apps/iOS/MNNLLMChat/MNNLLMiOS/LocalModel/Qwen2.5-Omni-3B-MNN diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index aed4231c..de784d2d 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/ActivityIndicatorView", "state" : { - "revision" : "9970fd0bb7a05dad0b6566ae1f56937716686b24", - "version" : "1.1.1" + "revision" : "36140867802ae4a1d2b11490bcbbefe058001d14", + "version" : "1.2.1" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Yogayu/Chat.git", "state" : { - "branch" : "main", - "revision" : "f9766674b74330dd77289ffb70904c465f0e4d94" + "revision" : "4c49b92811d42b8caa01e5c8562767640e19be56", + "version" : "1.0.1" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/FloatingButton", "state" : { - "revision" : "cf77c2f124df1423d90a9a1985e9b9ccfa4b9b3e", - "version" : "1.3.0" + "revision" : "c1e3e6a641ef5bacbcbc2b2c80db54793f75f974", + "version" : "1.4.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/johnmai-dev/Jinja", "state" : { - "revision" : "bbddb92fc51ae420b87300298370fd1dfc308f73", - "version" : "1.1.1" + "revision" : "bb238dd96fbe4c18014f3107c00edd6edb15428e", + "version" : "1.2.4" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-cmark", "state" : { - "revision" : "3ccff77b2dc5b96b77db3da0d68d28068593fa53", - "version" : "0.5.0" + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" } }, { @@ -105,8 +105,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/huggingface/swift-transformers/", "state" : { - "revision" : "55710ddfb1ae804b4b7ce973be75cf2e41272185", - "version" : "0.1.17" + "revision" : "e5bf0627bd134cf8ddc407cda403ae84207af959", + "version" : "0.1.22" } }, { From 5109f8b840b01bc04a94a7dec835ad3b178247d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 5 Sep 2025 15:29:26 +0800 Subject: [PATCH 24/25] [add] kernel function --- apps/iOS/MNNLLMChat/MNNLLMiOS/MNNLLMiOS.entitlements | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/iOS/MNNLLMChat/MNNLLMiOS/MNNLLMiOS.entitlements b/apps/iOS/MNNLLMChat/MNNLLMiOS/MNNLLMiOS.entitlements index f2ef3ae0..ca792257 100644 --- a/apps/iOS/MNNLLMChat/MNNLLMiOS/MNNLLMiOS.entitlements +++ b/apps/iOS/MNNLLMChat/MNNLLMiOS/MNNLLMiOS.entitlements @@ -2,9 +2,13 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.developer.kernel.extended-virtual-addressing + + com.apple.developer.kernel.increased-memory-limit + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + From d7028c9aa8f6e2622e69fba87a4f44d565faa9a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B8=B8=E8=96=AA=E6=B8=9D=28=E6=8F=BD=E6=B8=85=29?= Date: Fri, 5 Sep 2025 15:30:35 +0800 Subject: [PATCH 25/25] [update] readme --- apps/iOS/MNNLLMChat/README-ZH.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/iOS/MNNLLMChat/README-ZH.md b/apps/iOS/MNNLLMChat/README-ZH.md index a32c091f..c769c1fb 100644 --- a/apps/iOS/MNNLLMChat/README-ZH.md +++ b/apps/iOS/MNNLLMChat/README-ZH.md @@ -63,15 +63,22 @@ 2. 编译 MNN.framework: ```shell - cd MNN/ - sh package_scripts/ios/buildiOS.sh "-DMNN_ARM82=true -DMNN_LOW_MEMORY=true -DMNN_SUPPORT_TRANSFORMER_FUSE=true -DMNN_BUILD_LLM=true -DMNN_CPU_WEIGHT_DEQUANT_GEMM=true + sh package_scripts/ios/buildiOS.sh " + -DMNN_ARM82=ON + -DMNN_LOW_MEMORY=ON + -DMNN_SUPPORT_TRANSFORMER_FUSE=ON + -DMNN_BUILD_LLM=ON + -DMNN_CPU_WEIGHT_DEQUANT_GEMM=ON -DMNN_METAL=ON -DMNN_BUILD_DIFFUSION=ON - -DMNN_BUILD_OPENCV=ON - -DMNN_IMGCODECS=ON -DMNN_OPENCL=OFF -DMNN_SEP_BUILD=OFF - -DMNN_SUPPORT_TRANSFORMER_FUSE=ON" + -DLLM_SUPPORT_AUDIO=ON + -DMNN_BUILD_AUDIO=ON + -DLLM_SUPPORT_VISION=ON + -DMNN_BUILD_OPENCV=ON + -DMNN_IMGCODECS=ON + " ``` 3. 拷贝 framework 到 iOS 项目中