// MacLocalASRModelSettingsView.swift // OSGKeyboard · Mac // // Local ASR model catalog, download progress, and bias diagnostics. import AppKit import SwiftUI @MainActor final class MacLocalASRModelSettingsViewModel: ObservableObject { @Published var catalog: LocalASRCatalogDocument? @Published var selectedModelId: String = MacLocalASRPreferences.selectedModelId @Published var installProgress = LocalASRModelInstallProgress.idle @Published var diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load() @Published var statusMessage = "" @Published var isInstalling = false @Published var isDownloadPaused = false var onLocalModelStateChanged: (() -> Void)? private let manager = LocalASRModelManager.shared private var progressPollTask: Task? deinit { progressPollTask?.cancel() } func reload() { catalog = try? LocalASRModelCatalog.loadBundled() if let catalog { let manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId) selectedModelId = MacLocalASRPreferences.migratedModelId( manifest.selectedModelId.isEmpty ? MacLocalASRPreferences.selectedModelId : manifest.selectedModelId ) } diagnosticsSnapshot = LocalASRBiasDiagnosticsStore.load() onLocalModelStateChanged?() } func isInstalled(_ model: LocalASRModelDefinition) -> Bool { MacLocalASRService.isModelInstalled(model) } func isInstallingModel(_ model: LocalASRModelDefinition) -> Bool { isInstalling && installProgress.activeItemId == model.id } func installedDiskUsage(_ model: LocalASRModelDefinition) -> String? { guard model.installKind == .archive || model.installKind == .repository, let relative = model.installRelativePath, isInstalled(model) else { return nil } let dir = LocalASRModelInstallState.installDirectory(for: relative) let bytes = LocalASRModelInstallState.directoryByteCount(at: dir) guard bytes > 0 else { return nil } return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } func currentRuntime(in catalog: LocalASRCatalogDocument) -> LocalASRRuntimeDefinition? { LocalASRModelCatalog.runtime(for: LocalASRModelCatalog.currentRuntimePlatform(), in: catalog) } func isRuntimeInstalled(_ runtime: LocalASRRuntimeDefinition) -> Bool { LocalASRModelInstallState.isRuntimeInstalled(runtime) } func selectModel(_ modelId: String) { guard let catalog, !isInstalling else { return } selectedModelId = modelId MacLocalASRPreferences.selectedModelId = modelId var manifest = LocalASRInstalledManifestIO.load(defaultModelId: catalog.defaultModelId) manifest.selectedModelId = modelId manifest.updatedAt = Date() try? LocalASRInstalledManifestIO.save(manifest) onLocalModelStateChanged?() } func installModel(_ model: LocalASRModelDefinition) { guard let catalog, !isInstalling else { return } statusMessage = "" isInstalling = true isDownloadPaused = false startProgressPolling() Task { do { try await manager.installModel(model, catalog: catalog) installProgress = await manager.currentProgress() selectModel(model.id) statusMessage = MacL10n.string("mac.localASR.installDone") } catch { installProgress = await manager.currentProgress() statusMessage = error.localizedDescription } isInstalling = false isDownloadPaused = false stopProgressPolling() reload() } } func pauseDownload() { Task { do { try await manager.pauseDownload() isDownloadPaused = true installProgress = await manager.currentProgress() } catch { statusMessage = error.localizedDescription } } } func resumeDownload() { Task { do { try await manager.resumeDownload() isDownloadPaused = false installProgress = await manager.currentProgress() } catch { statusMessage = error.localizedDescription } } } func deleteModel(_ model: LocalASRModelDefinition) { guard let catalog, !isInstalling else { return } Task { do { try await manager.deleteModel(model, catalog: catalog) statusMessage = MacL10n.string("mac.localASR.deleteDone") reload() } catch { statusMessage = error.localizedDescription } } } func revealModelInFinder(_ model: LocalASRModelDefinition) { guard let relative = model.installRelativePath else { return } let url = LocalASRModelInstallState.installDirectory(for: relative) NSWorkspace.shared.activateFileViewerSelecting([url]) } func revealStorageRoot() { let url = LocalASRModelInstallState.rootDirectory() try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) NSWorkspace.shared.open(url) } func progressLabel(for progress: LocalASRModelInstallProgress, language: AppUILanguage) -> String { let phaseKey: String switch progress.phase { case .downloading: phaseKey = "mac.localASR.phase.downloading" case .paused: phaseKey = "mac.localASR.phase.paused" case .extracting: phaseKey = "mac.localASR.phase.extracting" case .validating: phaseKey = "mac.localASR.phase.validating" case .finalizing: phaseKey = "mac.localASR.phase.finalizing" case .failed: phaseKey = "mac.localASR.phase.failed" case .completed: phaseKey = "mac.localASR.phase.completed" case .idle: return progress.message } let phase = MacL10n.string(phaseKey, language: language) if let received = progress.bytesReceived, let total = progress.bytesTotal, total > 0 { let recv = ByteCountFormatter.string(fromByteCount: received, countStyle: .file) let tot = ByteCountFormatter.string(fromByteCount: total, countStyle: .file) return "\(phase) · \(progress.message) (\(recv) / \(tot))" } return "\(phase) · \(progress.message)" } private func startProgressPolling() { progressPollTask?.cancel() progressPollTask = Task { [weak self] in while !Task.isCancelled { guard let self else { return } let current = await manager.currentProgress() await MainActor.run { self.installProgress = current self.isDownloadPaused = current.phase == .paused } try? await Task.sleep(for: .milliseconds(120)) } } } private func stopProgressPolling() { progressPollTask?.cancel() progressPollTask = nil } func formattedSize(_ bytes: Int) -> String { ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) } } struct MacLocalASRModelSettingsView: View { @ObservedObject var viewModel: MacDictationViewModel @StateObject private var modelVM = MacLocalASRModelSettingsViewModel() @Environment(\.themePalette) private var palette private var lang: AppUILanguage { viewModel.config.uiLanguage } var body: some View { Group { if let catalog = modelVM.catalog { modelPickerSection(catalog: catalog) } else { Text(MacL10n.string("mac.localASR.catalogMissing", language: lang)) .foregroundStyle(palette.textSecondary) } } .onAppear { modelVM.onLocalModelStateChanged = { viewModel.bumpLocalModelRevision() } modelVM.reload() } } private func modelPickerSection(catalog: LocalASRCatalogDocument) -> some View { Section { if let runtime = modelVM.currentRuntime(in: catalog) { LabeledContent(runtime.displayName) { Text( modelVM.isRuntimeInstalled(runtime) ? MacL10n.string("mac.localASR.installed", language: lang) : MacL10n.string("mac.localASR.notInstalled", language: lang) ) } } ForEach(catalog.models) { model in modelRow(model) } if modelVM.isInstalling, modelVM.installProgress.phase == .extracting || modelVM.installProgress.phase == .validating || modelVM.installProgress.phase == .finalizing { ProgressView(value: modelVM.installProgress.fraction) { Text(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) .font(TypeStyle.caption) } } if !modelVM.statusMessage.isEmpty { Text(modelVM.statusMessage) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } Button(MacL10n.string("mac.localASR.openStorage", language: lang)) { modelVM.revealStorageRoot() } } header: { Text(MacL10n.string("mac.localASR.models", language: lang)) } } @ViewBuilder private func modelRow(_ model: LocalASRModelDefinition) -> some View { let installed = modelVM.isInstalled(model) let selected = modelVM.selectedModelId == model.id let installing = modelVM.isInstallingModel(model) VStack(alignment: .leading, spacing: Spacing.xs) { HStack(alignment: .top) { Button { modelVM.selectModel(model.id) } label: { HStack(spacing: Spacing.sm) { Image(systemName: selected ? "largecircle.fill.circle" : "circle") .foregroundStyle(selected ? palette.accent : palette.textTertiary) .contentTransition(.symbolEffect(.replace)) .animation(Motion.quick, value: selected) VStack(alignment: .leading, spacing: 2) { HStack(spacing: Spacing.xs) { Text(model.displayName) .foregroundStyle(palette.textPrimary) if model.supportsHotwords { Text(MacL10n.string("mac.localASR.personalDictionaryTag", language: lang)) .font(TypeStyle.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(palette.accent.opacity(0.15)) .foregroundStyle(palette.accent) .clipShape(Capsule()) } } Text(modelSubtitle(model, installed: installed)) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } } } .buttonStyle(.plain) .disabled(modelVM.isInstalling) Spacer() modelRowActions(model: model, installed: installed, installing: installing) .animation(Motion.soft, value: installing) .animation(Motion.soft, value: installed) } } .padding(.vertical, 2) } @ViewBuilder private func modelRowActions( model: LocalASRModelDefinition, installed: Bool, installing: Bool ) -> some View { if installing { HStack(spacing: Spacing.sm) { circularInstallProgress(for: model) if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused { Button { if modelVM.isDownloadPaused { modelVM.resumeDownload() } else { modelVM.pauseDownload() } } label: { Image(systemName: modelVM.isDownloadPaused ? "play.fill" : "pause.fill") .font(.system(size: 12, weight: .semibold)) .frame(width: 28, height: 28) } .buttonStyle(.bordered) .controlSize(.small) .help( modelVM.isDownloadPaused ? MacL10n.string("mac.localASR.resume", language: lang) : MacL10n.string("mac.localASR.pause", language: lang) ) } } } else if installed { Button(MacL10n.string("mac.localASR.delete", language: lang), role: .destructive) { modelVM.deleteModel(model) } .buttonStyle(.bordered) .controlSize(.small) .tint(palette.danger) } else { Button(MacL10n.string("mac.localASR.download", language: lang)) { modelVM.installModel(model) } .buttonStyle(.borderedProminent) .controlSize(.small) } } private func circularInstallProgress(for model: LocalASRModelDefinition) -> some View { let fraction: Double = { if modelVM.installProgress.phase == .downloading || modelVM.installProgress.phase == .paused, let received = modelVM.installProgress.bytesReceived, let total = modelVM.installProgress.bytesTotal, total > 0 { return min(1, max(0, Double(received) / Double(total))) } return modelVM.installProgress.fraction }() return ZStack { Circle() .stroke(palette.textTertiary.opacity(0.25), lineWidth: 3) Circle() .trim(from: 0, to: fraction) .stroke(palette.accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(Motion.instant, value: fraction) if modelVM.installProgress.phase == .paused { Image(systemName: "pause.fill") .font(.system(size: 10, weight: .bold)) .foregroundStyle(palette.textSecondary) } else { Text("\(Int(fraction * 100))%") .font(.system(size: 9, weight: .medium, design: .rounded)) .foregroundStyle(palette.textSecondary) } } .frame(width: 36, height: 36) .accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang)) } private func modelSubtitle(_ model: LocalASRModelDefinition, installed: Bool) -> String { let size = modelVM.formattedSize(model.sizeBytes) if let usage = modelVM.installedDiskUsage(model) { return "\(size) · \(usage)" } return size } private var diagnosticsSection: some View { Section { if let snapshot = modelVM.diagnosticsSnapshot { LabeledContent(MacL10n.string("mac.localASR.diagBackend", language: lang)) { Text(snapshot.backendLabel ?? "—") } LabeledContent(MacL10n.string("mac.localASR.diagUserTerms", language: lang)) { Text("\(snapshot.diagnostics.userTermCount)") } LabeledContent(MacL10n.string("mac.localASR.diagBuiltinTerms", language: lang)) { Text("\(snapshot.diagnostics.builtinTermCount)") } LabeledContent(MacL10n.string("mac.localASR.diagHotwords", language: lang)) { Text("\(snapshot.hotwordCount)") } LabeledContent(MacL10n.string("mac.localASR.diagPrompt", language: lang)) { Text("\(snapshot.promptBiasLength)") } if snapshot.diagnostics.truncated { Label( snapshot.diagnostics.truncationReason ?? MacL10n.string("mac.localASR.diagTruncated", language: lang), systemImage: "exclamationmark.triangle" ) .font(TypeStyle.caption) .foregroundStyle(palette.warning) } Text(snapshot.diagnostics.selectedSources.joined(separator: ", ")) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } else { Text(MacL10n.string("mac.localASR.diagEmpty", language: lang)) .foregroundStyle(palette.textSecondary) } } header: { Text(MacL10n.string("mac.localASR.diagnostics", language: lang)) } footer: { Text(MacL10n.string("mac.localASR.diagnosticsDesc", language: lang)) .font(TypeStyle.caption) .foregroundStyle(palette.textSecondary) } } }