diff --git a/App/Sources/Coordinator/MainCoordinator.swift b/App/Sources/Coordinator/MainCoordinator.swift index 6d04e295..288f332f 100644 --- a/App/Sources/Coordinator/MainCoordinator.swift +++ b/App/Sources/Coordinator/MainCoordinator.swift @@ -95,6 +95,7 @@ extension MainCoordinator: MainCoordinatorDelegate { Task { let isModelDownloaded = await dependencyContainer.isWhisperModelDownloaded() if isModelDownloaded { + dependencyContainer.preloadWhisperKit() let viewModel = dependencyContainer.makeRecordingViewModel() viewModel.coordinator = self viewModel.alertCoordinator = self @@ -210,14 +211,8 @@ extension MainCoordinator: ChaGokAlertCoordinatorDelegate { // MARK: - DownloadWhisperCoordinatorDelegate extension MainCoordinator: DownloadOnDeviceCoordinatorDelegate { - func dismissSheet(completion: Bool) { - if completion { // 모델 다운로드 완료 후 - presenter.dismiss(animated: true) { [weak self] in - self?.dependencyContainer.preloadWhisperKit() - } - } else { - presenter.dismiss(animated: true) - } + func dismissSheet() { + presenter.dismiss(animated: true) } } diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXHubDownloader.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXHubDownloader.swift new file mode 100644 index 00000000..fbc147ea --- /dev/null +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXHubDownloader.swift @@ -0,0 +1,36 @@ +import Foundation +import HuggingFace +import MLXHuggingFace +import MLXLMCommon + +/// Hugging Face Hub에서 모델 파일을 다운로드하기 위한 Downloader 구현체. +/// 컴파일 타임 매크로(#hubDownloader) 대신 사용되는 수동 구현체입니다. +public struct MLXHubDownloader: MLXLMCommon.Downloader { + private let upstream: HuggingFace.HubClient + + public init(hubClient: HuggingFace.HubClient = HuggingFace.HubClient()) { + upstream = hubClient + } + + public func download( + id: String, + revision: String?, + matching patterns: [String], + useLatest: Bool, + progressHandler: @Sendable @escaping (Foundation.Progress) -> Void + ) async throws -> URL { + guard let repoID = HuggingFace.Repo.ID(rawValue: id) else { + throw HuggingFaceDownloaderError.invalidRepositoryID(id) + } + let revision = revision ?? "main" + + return try await upstream.downloadSnapshot( + of: repoID, + revision: revision, + matching: patterns, + progressHandler: { @MainActor progress in + progressHandler(progress) + } + ) + } +} diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift index b61d7b01..17f21e77 100644 --- a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXModelProvider.swift @@ -43,7 +43,7 @@ public actor MLXModelProvider: MLXModelDataSource { let configuration = try matchModelConfiguration(model: model) let path = try await resolve( configuration: configuration, - from: #hubDownloader(), + from: MLXHubDownloader(), useLatest: false, progressHandler: progressHandler ) @@ -121,7 +121,7 @@ public actor MLXModelProvider: MLXModelDataSource { public nonisolated func loadModel() async throws(MLXModelDataSourceError) -> ModelContext { do { let from: URL = try await getDownloadPath() - let context = try await LLMModelFactory.shared.load(from: from, using: #huggingFaceTokenizerLoader()) + let context = try await LLMModelFactory.shared.load(from: from, using: MLXTokenizerLoader()) AppLogger.info("MLX model loaded: \(context)") return context } catch is CancellationError { diff --git a/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXTokenizerLoader.swift b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXTokenizerLoader.swift new file mode 100644 index 00000000..cb5282cf --- /dev/null +++ b/Data/Sources/Infrastructure/OnDevice/MLXSupport/MLXTokenizerLoader.swift @@ -0,0 +1,64 @@ +import Foundation +import MLXLMCommon +import Tokenizers + +/// Hugging Face 기반 토크나이저를 로드하기 위한 TokenizerLoader 구현체. +/// 컴파일 타임 매크로(#huggingFaceTokenizerLoader) 대신 사용되는 수동 구현체입니다. +public struct MLXTokenizerLoader: MLXLMCommon.TokenizerLoader { + public init() {} + + public func load(from directory: URL) async throws -> any MLXLMCommon.Tokenizer { + let upstream = try await Tokenizers.AutoTokenizer.from(modelFolder: directory) + return TokenizerBridge(upstream) + } +} + +private struct TokenizerBridge: MLXLMCommon.Tokenizer { + private let upstream: any Tokenizers.Tokenizer + + init(_ upstream: any Tokenizers.Tokenizer) { + self.upstream = upstream + } + + func encode(text: String, addSpecialTokens: Bool) -> [Int] { + upstream.encode(text: text, addSpecialTokens: addSpecialTokens) + } + + func decode(tokenIds: [Int], skipSpecialTokens: Bool) -> String { + upstream.decode(tokens: tokenIds, skipSpecialTokens: skipSpecialTokens) + } + + func convertTokenToId(_ token: String) -> Int? { + upstream.convertTokenToId(token) + } + + func convertIdToToken(_ id: Int) -> String? { + upstream.convertIdToToken(id) + } + + var bosToken: String? { + upstream.bosToken + } + + var eosToken: String? { + upstream.eosToken + } + + var unknownToken: String? { + upstream.unknownToken + } + + func applyChatTemplate( + messages: [[String: any Sendable]], + tools: [[String: any Sendable]]?, + additionalContext: [String: any Sendable]? + ) throws -> [Int] { + do { + return try upstream.applyChatTemplate( + messages: messages, tools: tools, additionalContext: additionalContext + ) + } catch Tokenizers.TokenizerError.missingChatTemplate { + throw MLXLMCommon.TokenizerError.missingChatTemplate + } + } +} diff --git a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift index 0cff43a3..e764652b 100644 --- a/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift +++ b/Data/Sources/Infrastructure/OnDevice/Whisper/WhisperKitProvider.swift @@ -56,13 +56,6 @@ public actor WhisperKitProvider: WhisperDataSource { progressCallback: progressHandler ) - // 다운로드 복귀 직후 태스크 취소 상태 감지 (레이스 컨디션 봉쇄) - if Task.isCancelled { - AppLogger.info("WhisperKit 다운로드 완료 복귀 후 취소 상태 감지 - 즉각 강제 소거 및 에러 방출") - try? storageService.delete(fileURL: path) - throw CancellationError() - } - modelDirectory = path AppLogger.info("WhisperKit 모델 위치 : \(modelDirectory?.path() ?? "없음")") } @@ -143,14 +136,7 @@ public actor WhisperKitProvider: WhisperDataSource { let relativePath = "huggingface/models/argmaxinc/whisperkit-coreml/\(recommendedModel)" let defaultPath = storageService.absoluteURL(for: relativePath) - let configPath = "\(relativePath)/config.json" - let vocabPath = "\(relativePath)/vocab.json" - - // 디렉토리 존재뿐만 아니라 핵심 구성 파일(config.json, vocab.json)의 완결성 검사를 수행하여 부분 다운로드 및 비정상 종료된 찌꺼기를 필터링합니다. - if storageService.exists(relativePath: relativePath), - storageService.exists(relativePath: configPath), - storageService.exists(relativePath: vocabPath) - { + if storageService.exists(relativePath: relativePath) { modelDirectory = defaultPath self.recommendedModel = recommendedModel AppLogger.info("whisper 저장 위치 (디스크 감지) : \(defaultPath)") diff --git a/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift b/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift index f37cffb6..d5345889 100644 --- a/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift +++ b/Data/Sources/Repositories/MLXSupport/DefaultAvailableModelSupportRepository.swift @@ -27,19 +27,19 @@ public final class DefaultAvailableModelSupportRepository: AvailableModelSupport /// 현재 사용자의 On-Device LLM 모두 fetch 합니다. public func fetchSupportModels() async -> [ChaGokModelState] { let models: [ChaGokModel] = ChaGokModel.models - var whisperStatus = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) - var mlxStatus = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + var whisperStatus = OnDeviceStatus(storage: .notDownloaded) + var mlxStatus = OnDeviceStatus(storage: .notDownloaded) do { _ = try await whisperProvider.getDownloadPath() - whisperStatus = OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + whisperStatus = OnDeviceStatus(storage: .downloaded) } catch { AppLogger.info("Whisper 모델 다운로드 경로 없음: \(error.localizedDescription)") } do { _ = try await mlxProvider.getDownloadPath() - mlxStatus = OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + mlxStatus = OnDeviceStatus(storage: .downloaded) } catch { AppLogger.info("MLX 모델 다운로드 경로 없음: \(error.localizedDescription)") } diff --git a/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift index 036b3fab..07401b32 100644 --- a/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift +++ b/Data/Sources/Repositories/OnDevice/DefaultMlxOnDeviceRepository.swift @@ -41,14 +41,14 @@ public final class DefaultMlxOnDeviceRepository: OnDeviceRepository { public func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { do { try await provider.delete() - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .notDownloaded) } catch { AppLogger.error(error) switch error { case .cancelled: throw .cancelled case .notFound, .downloadFailed, .deleteFailed: - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .notDownloaded) case .networkFailed: throw .deleteMLXFailed case .unknown(let underlying): @@ -67,9 +67,9 @@ public final class DefaultMlxOnDeviceRepository: OnDeviceRepository { public func checkStatus() async -> OnDeviceStatus { do { _ = try await provider.getDownloadPath() - return OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .downloaded) } catch { - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .notDownloaded) } } } diff --git a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift index 90757475..7c2ed777 100644 --- a/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift +++ b/Data/Sources/Repositories/OnDevice/DefaultWhisperOnDeviceRepository.swift @@ -41,7 +41,7 @@ public struct DefaultWhisperOnDeviceRepository: OnDeviceRepository { public func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { do { try await provider.delete() - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .notDownloaded) } catch { AppLogger.error(error) throw .deleteWhisperFailed @@ -51,9 +51,9 @@ public struct DefaultWhisperOnDeviceRepository: OnDeviceRepository { public func checkStatus() async -> OnDeviceStatus { do { _ = try await provider.getDownloadPath() - return OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .downloaded) } catch { - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .notDownloaded) } } } diff --git a/Domain/Sources/Entities/ChaGokModelSupport.swift b/Domain/Sources/Entities/ChaGokModelSupport.swift index 31766c4c..c79797b8 100644 --- a/Domain/Sources/Entities/ChaGokModelSupport.swift +++ b/Domain/Sources/Entities/ChaGokModelSupport.swift @@ -49,7 +49,7 @@ public struct ChaGokModelState: Hashable, Sendable { title: String, subTitle: String, model: ChaGokModel, - status: OnDeviceStatus = .init(storage: .notDownloaded, runtime: .unloaded) + status: OnDeviceStatus = .init(storage: .notDownloaded) ) { self.title = title self.subTitle = subTitle diff --git a/Domain/Sources/Entities/OnDeviceStatus.swift b/Domain/Sources/Entities/OnDeviceStatus.swift index 4ff06585..b156c940 100644 --- a/Domain/Sources/Entities/OnDeviceStatus.swift +++ b/Domain/Sources/Entities/OnDeviceStatus.swift @@ -6,15 +6,11 @@ import Foundation public struct OnDeviceStatus: Hashable, Sendable { /// 디스크 또는 캐시 상의 저장 상태입니다. public var storage: StorageState - /// 메모리 상에서 모델이 준비된 상태입니다. - public var runtime: RuntimeState public init( - storage: StorageState = .notDownloaded, - runtime: RuntimeState = .unloaded + storage: StorageState = .notDownloaded ) { self.storage = storage - self.runtime = runtime } /// 다운로드 및 삭제처럼, 모델 파일의 보관 상태를 나타냅니다. @@ -28,14 +24,4 @@ public struct OnDeviceStatus: Hashable, Sendable { /// 저장 단계에서 실패한 상태입니다. case failed } - - /// 로드처럼, 메모리 적재 여부를 나타냅니다. - public enum RuntimeState: Sendable, Hashable { - /// 메모리에 올려지지 않은 상태입니다. - case unloaded - /// 메모리 로드가 진행 중인 상태입니다. - case loading - /// 메모리에 적재된 상태입니다. - case loaded - } } diff --git a/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift b/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift index 3e78b23e..51942f71 100644 --- a/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift +++ b/Domain/Sources/UseCases/OnDevice/OnDeviceStatusUseCase.swift @@ -20,6 +20,10 @@ public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { private var latest: [ChaGokModel: OnDeviceStatus] = [:] private var subscribers: [UUID: (model: ChaGokModel, cont: AsyncStream.Continuation)] = [:] + /// 모델별 현재 활성 다운로드 Task 및 식별자 + private var downloadTasks: [ChaGokModel: Task] = [:] + private var downloadIDs: [ChaGokModel: UUID] = [:] + public init( whisperRepository: any OnDeviceRepository, mlxRepository: any OnDeviceRepository @@ -43,52 +47,84 @@ public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { } public func download(model: ChaGokModel) async throws(OnDeviceStatusUseCaseError) { - guard isDownloading[model] != true, let repo = repo(for: model) else { return } + guard let repo = repo(for: model) else { return } + // 기존 다운로드가 있으면 취소 + downloadTasks[model]?.cancel() + downloadTasks[model] = nil + + let downloadID = UUID() + downloadIDs[model] = downloadID isDownloading[model] = true - defer { isDownloading[model] = false } - do { - // 다운로드 시작 상태 알림 - await publish(model: model, status: OnDeviceStatus(storage: .downloading(progress: 0), runtime: .unloaded)) + await publish(model: model, status: OnDeviceStatus(storage: .downloading(progress: 0))) + // 취소 가능한 내부 Task로 감싸서 관리 + let task = Task { try await repo.download { progress in - Task { [model] in - guard await self.shouldPublishProgress(model: model) else { return } + Task { [model, downloadID] in + // 이 다운로드가 아직 활성 상태인 경우에만 progress 발행 + guard await self.downloadIDs[model] == downloadID else { return } await self.publish( model: model, - status: OnDeviceStatus(storage: .downloading(progress: progress), runtime: .unloaded) + status: OnDeviceStatus(storage: .downloading(progress: progress)) ) } } + } + downloadTasks[model] = task + + do { + try await task.value - // 다운로드 완료 상태 알림 - await publish(model: model, status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded)) + // 이 다운로드가 아직 활성 상태인 경우에만 완료 처리 + guard downloadIDs[model] == downloadID else { return } + downloadTasks[model] = nil + isDownloading[model] = false + await publish(model: model, status: OnDeviceStatus(storage: .downloaded)) } catch { - let mappedError: OnDeviceStatusUseCaseError = switch error { - case .cancelled: + // 이 다운로드가 이미 교체된 경우(새 다운로드가 시작됨) 조용히 종료 + guard downloadIDs[model] == downloadID else { + throw .cancelled + } + downloadTasks[model] = nil + isDownloading[model] = false + + let mappedError: OnDeviceStatusUseCaseError = if error is CancellationError { .cancelled - case .networkFailed: - .networkFailed - case .loadFailed: - .loadFailed - case .unknown(let underlying): - .unknown(underlying) + } else if let repoError = error as? OnDeviceRepositoryError { + switch repoError { + case .cancelled: + .cancelled + case .networkFailed: + .networkFailed + case .loadFailed: + .loadFailed + case .unknown(let underlying): + .unknown(underlying) + } + } else { + .unknown(error) } AppLogger.error(mappedError) if case .cancelled = mappedError { // 사용자 취소 시 상태를 .notDownloaded로 복구하여 구독 모델들에 알림 - await publish(model: model, status: OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded)) + await publish(model: model, status: OnDeviceStatus(storage: .notDownloaded)) } else { - await publish(model: model, status: OnDeviceStatus(storage: .failed, runtime: .unloaded)) + await publish(model: model, status: OnDeviceStatus(storage: .failed)) } throw mappedError } } public func delete(model: ChaGokModel) async throws(DeleteOnDeviceRepositoryError) { + // 진행 중인 다운로드 취소 + downloadTasks[model]?.cancel() + downloadTasks[model] = nil + downloadIDs[model] = nil isDownloading[model] = false + guard let repo = repo(for: model) else { return } do { let status = try await repo.delete() @@ -101,18 +137,14 @@ public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { public func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { if isDownloading[model] == true { - return latest[model] ?? OnDeviceStatus(storage: .downloading(progress: 0.0), runtime: .unloaded) + return latest[model] ?? OnDeviceStatus(storage: .downloading(progress: 0.0)) } if let repo = repo(for: model) { let status = await repo.checkStatus() latest[model] = status return status } - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) - } - - private func shouldPublishProgress(model: ChaGokModel) -> Bool { - return isDownloading[model] == true + return OnDeviceStatus(storage: .notDownloaded) } private func syncStatus(model: ChaGokModel) async { @@ -122,6 +154,11 @@ public actor DefaultOnDeviceStatusUseCase: OnDeviceStatusUseCase { private var lastPublishedTime: [ChaGokModel: Double] = [:] private func publish(model: ChaGokModel, status: OnDeviceStatus) async { + // 취소/삭제 후 남아있는 progress 콜백이 .downloading을 다시 발행하는 것을 방지 + if case .downloading = status.storage, isDownloading[model] != true { + return + } + if case .downloading(let progress) = status.storage { let currentTime = Date().timeIntervalSince1970 let lastTime = lastPublishedTime[model] ?? 0.0 diff --git a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift index 587dbb65..c68b6169 100644 --- a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift +++ b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceRepository.swift @@ -8,10 +8,9 @@ public final class MockOnDeviceRepository: OnDeviceRepository { public var downloadResult: Result = .success(()) public var deleteResult: Result = .success(OnDeviceStatus( - storage: .notDownloaded, - runtime: .unloaded + storage: .notDownloaded )) - public var checkStatusResult: OnDeviceStatus = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + public var checkStatusResult: OnDeviceStatus = OnDeviceStatus(storage: .notDownloaded) public var downloadProgressValues: [Double] = [] diff --git a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift index dbb8485b..37e820c1 100644 --- a/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift +++ b/Domain/Testing/Interfaces/Mocks/OnDevice/MockOnDeviceStatusUseCase.swift @@ -8,8 +8,7 @@ public final class MockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked public var subscribeStream: AsyncStream? public var downloadResult: Result = .success(()) public var deleteResult: Result = .success(OnDeviceStatus( - storage: .notDownloaded, - runtime: .unloaded + storage: .notDownloaded )) public var actualSubscribeCallCount = 0 @@ -23,8 +22,7 @@ public final class MockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked public var checkedModel: ChaGokModel? public var checkStatusResult: OnDeviceStatus = OnDeviceStatus( - storage: .notDownloaded, - runtime: .unloaded + storage: .notDownloaded ) public func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { diff --git a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift index 1f389ed4..0a2f6775 100644 --- a/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift +++ b/Presentation/Sources/ViewModel/OnBoarding/OnBoardingViewModel.swift @@ -54,8 +54,7 @@ public final class OnBoardingViewModel { private(set) var modelSupport: Bool = false private(set) var downloadTask: Task? private(set) var status: OnDeviceStatus = .init( - storage: .notDownloaded, - runtime: .unloaded + storage: .notDownloaded ) private(set) var scrollEnabled: Bool = true @@ -212,31 +211,31 @@ extension OnBoardingViewModel { scrollEnabled = true if Task.isCancelled { AppLogger.debug("Download Task Cancelled!!") - status = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + status = OnDeviceStatus(storage: .notDownloaded) } } do { - self.status = OnDeviceStatus(storage: .downloading(progress: 0), runtime: .unloaded) + self.status = OnDeviceStatus(storage: .downloading(progress: 0)) try await mlxRepository.download { progress in Task { @MainActor in guard case .downloading = self.status.storage else { return } - self.status = OnDeviceStatus(storage: .downloading(progress: progress), runtime: .unloaded) + self.status = OnDeviceStatus(storage: .downloading(progress: progress)) } } - self.status = OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + self.status = OnDeviceStatus(storage: .downloaded) } catch let repoError as OnDeviceRepositoryError { AppLogger.error(repoError) if case .cancelled = repoError { - self.status = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + self.status = OnDeviceStatus(storage: .notDownloaded) } else { self.errorMessage = repoError.errorDescription AppLogger.info(errorMessage ?? "nil") - self.status = OnDeviceStatus(storage: .failed, runtime: .unloaded) + self.status = OnDeviceStatus(storage: .failed) } } catch { AppLogger.error(error) self.errorMessage = error.localizedDescription - self.status = OnDeviceStatus(storage: .failed, runtime: .unloaded) + self.status = OnDeviceStatus(storage: .failed) } } } @@ -318,7 +317,7 @@ extension OnBoardingViewModel { struct PreviewOnDeviceRepository: OnDeviceRepository { func checkStatus() async -> Domain.OnDeviceStatus { - .init(storage: .downloaded, runtime: .unloaded) + .init(storage: .downloaded) } func download(progressHandler: @Sendable @escaping (Double) -> Void) async throws(OnDeviceRepositoryError) { @@ -337,7 +336,7 @@ extension OnBoardingViewModel { } func delete() async throws(DeleteOnDeviceRepositoryError) -> OnDeviceStatus { - OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + OnDeviceStatus(storage: .notDownloaded) } } } diff --git a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift index 64e62558..f977c408 100644 --- a/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift +++ b/Presentation/Sources/ViewModel/Recording/DownloadOnDeviceViewModel.swift @@ -6,7 +6,7 @@ import Foundation public protocol DownloadOnDeviceCoordinatorDelegate: AnyObject { /// 시트를 닫습니다 /// - Parameter completion: true: 다운로드 완료 / false: 취소 또는 나중에 - func dismissSheet(completion: Bool) + func dismissSheet() } @MainActor @@ -15,7 +15,7 @@ public final class DownloadOnDeviceViewModel { // MARK: - State /// 온디바이스 모델의 통합 상태값 - private(set) var status: OnDeviceStatus = .init(storage: .notDownloaded, runtime: .unloaded) + private(set) var status: OnDeviceStatus = .init(storage: .notDownloaded) private(set) var errorMessage: String? public weak var coordinator: DownloadOnDeviceCoordinatorDelegate? @@ -55,7 +55,7 @@ extension DownloadOnDeviceViewModel { status = newStatus AppLogger.debug("OnDeviceStatus: \(newStatus)") if newStatus.storage == .downloaded { - dismiss() // 다운로드 완료 시 dismiss + dismiss() } } } @@ -83,7 +83,7 @@ extension DownloadOnDeviceViewModel { func cancelDownload() { let task = downloadTask downloadTask = nil - status = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + status = OnDeviceStatus(storage: .notDownloaded) let useCase = onDeviceStatusUseCase Task { @@ -109,6 +109,6 @@ extension DownloadOnDeviceViewModel { } else { task?.cancel() } - coordinator?.dismissSheet(completion: true) + coordinator?.dismissSheet() } } diff --git a/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift b/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift index b65a977f..fdab2166 100644 --- a/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift +++ b/Presentation/Sources/ViewModel/Setting/SettingViewModel+Preview.swift @@ -39,13 +39,13 @@ import Foundation title: "Gemma-4", subTitle: "AI 요약 모델", model: .gemma4_e2b_4bit, - status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .downloaded) ), ChaGokModelState( title: "Whisper", subTitle: "음성 전사 모델", model: .whisper, - status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .downloaded) ) ] } @@ -53,14 +53,14 @@ import Foundation actor PreviewOnDeviceStatusUseCase: OnDeviceStatusUseCase { func checkStatus(model: Domain.ChaGokModel) async -> Domain.OnDeviceStatus { - .init(storage: .downloaded, runtime: .unloaded) + .init(storage: .downloaded) } func cancelDownload(model: Domain.ChaGokModel) async {} func subscribe(model: ChaGokModel) async -> AsyncStream { AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - continuation.yield(OnDeviceStatus(storage: .downloaded, runtime: .unloaded)) + continuation.yield(OnDeviceStatus(storage: .downloaded)) continuation.finish() } } diff --git a/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift b/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift index 58ebb417..09ff1b30 100644 --- a/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift +++ b/Presentation/Tests/Recording/DownloadOnDeviceViewModelTests.swift @@ -6,11 +6,9 @@ import XCTest @MainActor final class MockDownloadOnDeviceCoordinator: DownloadOnDeviceCoordinatorDelegate { private(set) var dismissSheetCallCount = 0 - private(set) var completionValue: Bool? - func dismissSheet(completion: Bool) { + func dismissSheet() { dismissSheetCallCount += 1 - completionValue = completion } } @@ -22,7 +20,7 @@ final class DownloadMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked func subscribe(model: ChaGokModel) -> AsyncStream { AsyncStream { cont in - cont.yield(OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded)) + cont.yield(OnDeviceStatus(storage: .notDownloaded)) cont.finish() } } @@ -42,7 +40,7 @@ final class DownloadMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked } func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { - return OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + return OnDeviceStatus(storage: .notDownloaded) } } @@ -137,6 +135,5 @@ extension DownloadOnDeviceViewModelTests { // Then XCTAssertEqual(sut.coordinator.dismissSheetCallCount, 1) - XCTAssertTrue(sut.coordinator.completionValue ?? false) } } diff --git a/Presentation/Tests/Setting/SettingViewModelTests.swift b/Presentation/Tests/Setting/SettingViewModelTests.swift index a34e2a38..8817ffae 100644 --- a/Presentation/Tests/Setting/SettingViewModelTests.swift +++ b/Presentation/Tests/Setting/SettingViewModelTests.swift @@ -35,7 +35,7 @@ final class SettingMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked func subscribe(model: ChaGokModel) -> AsyncStream { AsyncStream { cont in self.continuation = cont - cont.yield(OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded)) + cont.yield(OnDeviceStatus(storage: .notDownloaded)) } } @@ -44,9 +44,9 @@ final class SettingMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked lastDownloadedModel = model switch downloadResult { case .success: - emit(status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded)) + emit(status: OnDeviceStatus(storage: .downloaded)) case .failure(let error): - emit(status: OnDeviceStatus(storage: .failed, runtime: .unloaded)) + emit(status: OnDeviceStatus(storage: .failed)) throw error } } @@ -56,7 +56,7 @@ final class SettingMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked lastDeletedModel = model switch deleteResult { case .success: - emit(status: OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded)) + emit(status: OnDeviceStatus(storage: .notDownloaded)) case .failure(let error): throw error } @@ -66,7 +66,7 @@ final class SettingMockOnDeviceStatusUseCase: OnDeviceStatusUseCase, @unchecked continuation?.yield(status) } - var checkStatusResult: OnDeviceStatus = OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + var checkStatusResult: OnDeviceStatus = OnDeviceStatus(storage: .notDownloaded) func checkStatus(model: ChaGokModel) async -> OnDeviceStatus { return checkStatusResult @@ -168,13 +168,13 @@ final class SettingViewModelTests: XCTestCase { title: "whisper title", subTitle: "whisper subTitle", model: .whisper, - status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .downloaded) ), ChaGokModelState( title: "gemma4 title", subTitle: "gemma4 subTitle", model: .gemma4_e2b_4bit, - status: OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .notDownloaded) ) ] sut.mockAvailableModelRepo.setFetchSupportModelsResult(mockModels) @@ -214,7 +214,7 @@ final class SettingViewModelTests: XCTestCase { title: "whisper title", subTitle: "whisper subTitle", model: .whisper, - status: OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .notDownloaded) ) ]) @@ -237,7 +237,7 @@ final class SettingViewModelTests: XCTestCase { title: "gemma4 title", subTitle: "gemma4 subTitle", model: .gemma4_e2b_4bit, - status: OnDeviceStatus(storage: .notDownloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .notDownloaded) ) ]) @@ -262,7 +262,7 @@ final class SettingViewModelTests: XCTestCase { title: "whisper title", subTitle: "whisper subTitle", model: .whisper, - status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .downloaded) ) ]) @@ -285,7 +285,7 @@ final class SettingViewModelTests: XCTestCase { title: "gemma4 title", subTitle: "gemma4 subTitle", model: .gemma4_e2b_4bit, - status: OnDeviceStatus(storage: .downloaded, runtime: .unloaded) + status: OnDeviceStatus(storage: .downloaded) ) ])