Files
OSGKeyboard/OSGKeyboardMac/MacLocalASRModelSettingsView.swift
T
Rocky 39002336c0
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled
feat(app): add flow diagnostics and refine interface
Improve failure recovery visibility, strengthen account-session handling, and make the cross-platform interface more consistent.
2026-08-26 14:40:06 +08:00

510 lines
21 KiB
Swift

// 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 downloadSource: LocalASRDownloadSourcePreference = MacLocalASRPreferences.downloadSource
@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<Void, Never>?
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 setDownloadSource(_ source: LocalASRDownloadSourcePreference) {
downloadSource = source
MacLocalASRPreferences.downloadSource = source
}
func installModel(_ model: LocalASRModelDefinition) {
guard let catalog, !isInstalling else { return }
statusMessage = ""
isInstalling = true
isDownloadPaused = false
startProgressPolling()
let preferredSource = downloadSource
Task {
do {
try await manager.installModel(model, catalog: catalog, preferredSource: preferredSource)
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
/// Only one model installs at a time, so a single hover flag drives the
/// active download ring's pause/resume affordance.
@State private var isHoveringProgress = false
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 {
MacSettingsSection(title: MacL10n.string("mac.localASR.models", language: lang)) {
VStack(spacing: MacMetrics.settingsRowGap) {
ForEach(Array(catalog.models.enumerated()), id: \.element.id) { _, model in
modelRow(model)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
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)
}
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
if !modelVM.statusMessage.isEmpty {
Text(modelVM.statusMessage)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.frame(minHeight: MacMetrics.settingsRowMinHeight)
.padding(.horizontal, MacMetrics.settingsCardInset)
}
downloadSourceRow
HStack(spacing: 0) {
MacSettingsToolButton(title: MacL10n.string("mac.localASR.openStorage", language: lang)) {
modelVM.revealStorageRoot()
}
Spacer(minLength: 0)
}
.padding(.horizontal, MacMetrics.settingsCardInset)
}
}
}
private var downloadSourceRow: some View {
MacProviderSettingRow(
title: MacL10n.string("mac.localASR.downloadSource", language: lang)
) {
MacInlinePicker(
selection: Binding(
get: { modelVM.downloadSource },
set: { modelVM.setDownloadSource($0) }
),
options: [
MacInlinePickerOption(
value: LocalASRDownloadSourcePreference.auto,
label: MacL10n.string("mac.localASR.downloadSource.auto", language: lang)
),
MacInlinePickerOption(
value: LocalASRDownloadSourcePreference.hfMirror,
label: MacL10n.string("mac.localASR.downloadSource.hfMirror", language: lang)
),
MacInlinePickerOption(
value: LocalASRDownloadSourcePreference.huggingface,
label: MacL10n.string("mac.localASR.downloadSource.huggingface", language: lang)
)
],
fillsWidth: true
)
.disabled(modelVM.isInstalling)
}
}
@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 let badgeKey = model.badgeKey {
modelBadge(
MacL10n.string(badgeKey, language: lang),
emphasized: true
)
}
if model.supportsHotwords {
modelBadge(
MacL10n.string("mac.localASR.personalDictionaryTag", language: lang),
emphasized: false
)
}
}
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, 0)
}
private func modelBadge(_ title: String, emphasized: Bool) -> some View {
Text(title)
.font(TypeStyle.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(
emphasized
? palette.accent.opacity(0.18)
: palette.textTertiary.opacity(0.12)
)
.foregroundStyle(emphasized ? palette.accent : palette.textSecondary)
.clipShape(Capsule())
}
@ViewBuilder
private func modelRowActions(
model: LocalASRModelDefinition,
installed: Bool,
installing: Bool
) -> some View {
if installing {
installProgressControl(for: model)
.animation(Motion.quick, value: isHoveringProgress)
.animation(Motion.quick, value: modelVM.isDownloadPaused)
} else if installed {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.delete", language: lang),
fill: palette.danger.opacity(0.15),
foreground: palette.danger
) {
modelVM.deleteModel(model)
}
} else {
MacSettingsToolButton(
title: MacL10n.string("mac.localASR.download", language: lang),
fill: palette.accent,
foreground: OSGColor.fixedLightContent
) {
modelVM.installModel(model)
}
}
}
/// Single download ring. Shows the percentage while downloading; on hover it
/// reveals a clickable pause icon, and once paused it keeps a resume (play)
/// icon so the state stays clear without a second button.
private func installProgressControl(for model: LocalASRModelDefinition) -> some View {
let phase = modelVM.installProgress.phase
let pausable = phase == .downloading || phase == .paused
let paused = modelVM.isDownloadPaused
let fraction: Double = {
if pausable,
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)
progressCenter(pausable: pausable, paused: paused, fraction: fraction)
}
.frame(width: 36, height: 36)
.contentShape(Circle())
.onHover { hovering in
guard pausable else { return }
isHoveringProgress = hovering
}
.onTapGesture {
guard pausable else { return }
if paused { modelVM.resumeDownload() } else { modelVM.pauseDownload() }
}
.help(
pausable
? (paused
? MacL10n.string("mac.localASR.resume", language: lang)
: MacL10n.string("mac.localASR.pause", language: lang))
: modelVM.progressLabel(for: modelVM.installProgress, language: lang)
)
.accessibilityLabel(modelVM.progressLabel(for: modelVM.installProgress, language: lang))
}
@ViewBuilder
private func progressCenter(pausable: Bool, paused: Bool, fraction: Double) -> some View {
if pausable, paused {
Image(systemName: "play.fill")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.accent)
} else if pausable, isHoveringProgress {
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)
}
}
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)
}
}
}