chore: adiciona .gitignore e commit.command
This commit is contained in:
+525
@@ -0,0 +1,525 @@
|
||||
// MARK: – ModelDownloadFeature.swift
|
||||
|
||||
// A full‐featured TCA reducer for managing on‑device ML models.
|
||||
// Dependencies: ComposableArchitecture, IdentifiedCollections, Dependencies
|
||||
|
||||
import AppKit
|
||||
import ComposableArchitecture
|
||||
import Dependencies
|
||||
import HexCore
|
||||
import IdentifiedCollections
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Data Models
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public struct ModelInfo: Equatable, Identifiable {
|
||||
public let name: String
|
||||
public var isDownloaded: Bool
|
||||
|
||||
public var id: String { name }
|
||||
public init(name: String, isDownloaded: Bool) {
|
||||
self.name = name
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
}
|
||||
|
||||
public struct CuratedModelInfo: Equatable, Identifiable, Codable {
|
||||
public let displayName: String
|
||||
public let internalName: String
|
||||
public let size: String
|
||||
public let accuracyStars: Int
|
||||
public let speedStars: Int
|
||||
public let storageSize: String
|
||||
public var isDownloaded: Bool
|
||||
public var id: String { internalName }
|
||||
|
||||
public var badge: String? {
|
||||
switch parakeetModel {
|
||||
case .englishV2:
|
||||
return "BEST FOR ENGLISH"
|
||||
case .multilingualV3:
|
||||
return "BEST FOR MULTILINGUAL"
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var parakeetModel: ParakeetModel? {
|
||||
ParakeetModel(rawValue: internalName)
|
||||
}
|
||||
|
||||
var isParakeet: Bool {
|
||||
parakeetModel != nil
|
||||
}
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
internalName: String,
|
||||
size: String,
|
||||
accuracyStars: Int,
|
||||
speedStars: Int,
|
||||
storageSize: String,
|
||||
isDownloaded: Bool
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.internalName = internalName
|
||||
self.size = size
|
||||
self.accuracyStars = accuracyStars
|
||||
self.speedStars = speedStars
|
||||
self.storageSize = storageSize
|
||||
self.isDownloaded = isDownloaded
|
||||
}
|
||||
|
||||
// Codable (isDownloaded is set at runtime)
|
||||
private enum CodingKeys: String, CodingKey { case displayName, internalName, size, accuracyStars, speedStars, storageSize }
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
displayName = try c.decode(String.self, forKey: .displayName)
|
||||
internalName = try c.decode(String.self, forKey: .internalName)
|
||||
size = try c.decode(String.self, forKey: .size)
|
||||
accuracyStars = try c.decode(Int.self, forKey: .accuracyStars)
|
||||
speedStars = try c.decode(Int.self, forKey: .speedStars)
|
||||
storageSize = try c.decode(String.self, forKey: .storageSize)
|
||||
isDownloaded = false
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience helper for loading the bundled models.json once.
|
||||
private enum CuratedModelLoader {
|
||||
private static let bundledModels: [CuratedModelInfo] = {
|
||||
guard let url = Bundle.main.url(forResource: "models", withExtension: "json") ??
|
||||
Bundle.main.url(forResource: "models", withExtension: "json", subdirectory: "Data")
|
||||
else {
|
||||
assertionFailure("models.json not found in bundle")
|
||||
return []
|
||||
}
|
||||
do { return try JSONDecoder().decode([CuratedModelInfo].self, from: Data(contentsOf: url)) }
|
||||
catch { assertionFailure("Failed to decode models.json - \(error)"); return [] }
|
||||
}()
|
||||
|
||||
static func load() -> [CuratedModelInfo] {
|
||||
bundledModels
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// MARK: – Domain
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@Reducer
|
||||
public struct ModelDownloadFeature {
|
||||
@ObservableState
|
||||
public struct State: Equatable {
|
||||
// Shared user settings
|
||||
@Shared(.hexSettings) var hexSettings: HexSettings
|
||||
@Shared(.modelBootstrapState) var modelBootstrapState: ModelBootstrapState
|
||||
|
||||
// Remote data
|
||||
public var availableModels: IdentifiedArrayOf<ModelInfo> = []
|
||||
public var curatedModels = IdentifiedArrayOf(uniqueElements: CuratedModelLoader.load())
|
||||
public var recommendedModel: String = ""
|
||||
|
||||
// UI state
|
||||
public var isLoadingModels = false
|
||||
public var isDownloading = false
|
||||
public var downloadProgress: Double = 0
|
||||
public var downloadError: String?
|
||||
public var downloadingModelName: String?
|
||||
|
||||
// Track which model generated a progress update to handle switching models
|
||||
public var activeDownloadID: UUID?
|
||||
|
||||
// Convenience computed vars
|
||||
var selectedModel: String { hexSettings.selectedModel }
|
||||
|
||||
/// The downloaded model matching the current selection, pattern-aware so
|
||||
/// legacy or glob-style selections (e.g. "distil*large-v3") still resolve.
|
||||
private var downloadedModelMatchingSelection: ModelInfo? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
return availableModels.first { model in
|
||||
model.isDownloaded && ModelPatternMatcher.namesMatch(model.name, selectedModel)
|
||||
}
|
||||
}
|
||||
|
||||
var selectedModelNameForDisplay: String? {
|
||||
guard !selectedModel.isEmpty else { return nil }
|
||||
if let downloaded = downloadedModelMatchingSelection {
|
||||
return downloaded.name
|
||||
}
|
||||
if modelBootstrapState.isModelReady,
|
||||
let identifier = modelBootstrapState.modelIdentifier,
|
||||
ModelPatternMatcher.namesMatch(identifier, selectedModel)
|
||||
{
|
||||
return selectedModel
|
||||
}
|
||||
return hexSettings.hasCompletedModelBootstrap ? selectedModel : nil
|
||||
}
|
||||
|
||||
var selectedModelIsDownloaded: Bool {
|
||||
downloadedModelMatchingSelection != nil
|
||||
}
|
||||
|
||||
var anyModelDownloaded: Bool {
|
||||
availableModels.contains(where: { $0.isDownloaded })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Actions
|
||||
|
||||
public enum Action: BindableAction {
|
||||
case binding(BindingAction<State>)
|
||||
// Requests
|
||||
case fetchModels
|
||||
case selectModel(String)
|
||||
case downloadModel(String)
|
||||
// Effects
|
||||
case modelsLoaded(recommended: String, available: [ModelInfo])
|
||||
case modelsLoadFailed
|
||||
case downloadProgress(id: UUID, progress: Double)
|
||||
case downloadCompleted(id: UUID, result: Result<String, Error>)
|
||||
case cancelDownload
|
||||
|
||||
case deleteModel(String)
|
||||
case modelDeleted(String)
|
||||
case modelDeletionFailed(Error)
|
||||
case openModelLocation(String)
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
@Dependency(\.transcription) var transcription
|
||||
|
||||
public init() {}
|
||||
|
||||
// MARK: Reducer
|
||||
|
||||
public var body: some ReducerOf<Self> {
|
||||
BindingReducer()
|
||||
Reduce(reduce)
|
||||
}
|
||||
|
||||
// MARK: - Helpers (pattern matching)
|
||||
|
||||
private func resolvePattern(_ pattern: String, from available: [ModelInfo]) -> String? {
|
||||
ModelPatternMatcher.resolvePattern(pattern, from: available.map { ($0.name, $0.isDownloaded) })
|
||||
}
|
||||
|
||||
private func curatedDisplayName(for model: String, curated: IdentifiedArrayOf<CuratedModelInfo>) -> String {
|
||||
if let match = curated.first(where: { ModelPatternMatcher.matches($0.internalName, model) }) {
|
||||
return match.displayName
|
||||
}
|
||||
return model
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
|
||||
private func updateBootstrapState(_ state: inout State) {
|
||||
let model = state.hexSettings.selectedModel
|
||||
guard !model.isEmpty else {
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = ""
|
||||
bootstrap.modelDisplayName = ""
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.progress = 0
|
||||
bootstrap.lastError = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
let isDownloaded = state.selectedModelIsDownloaded
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
bootstrap.modelIdentifier = model
|
||||
bootstrap.modelDisplayName = displayName
|
||||
bootstrap.isModelReady = isDownloaded
|
||||
if isDownloaded {
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reduce(state: inout State, action: Action) -> Effect<Action> {
|
||||
switch action {
|
||||
// MARK: – UI bindings
|
||||
|
||||
case .binding:
|
||||
return .none
|
||||
|
||||
case let .selectModel(model):
|
||||
// If the curated item is a glob (e.g., "distil*large-v3"),
|
||||
// resolve it to a concrete available model so both tabs stay in sync
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
// MARK: – Fetch Models
|
||||
|
||||
case .fetchModels:
|
||||
guard !state.isLoadingModels else { return .none }
|
||||
state.isLoadingModels = true
|
||||
return .run { send in
|
||||
do {
|
||||
async let recommendedSupportTask = transcription.getRecommendedModels()
|
||||
async let availableNamesTask = transcription.getAvailableModels()
|
||||
let recommendedSupport = try await recommendedSupportTask
|
||||
let names = try await availableNamesTask
|
||||
let recommended = recommendedSupport.default
|
||||
let infos = try await withThrowingTaskGroup(of: ModelInfo.self) { group -> [ModelInfo] in
|
||||
for name in names {
|
||||
group.addTask {
|
||||
ModelInfo(
|
||||
name: name,
|
||||
isDownloaded: await transcription.isModelDownloaded(name)
|
||||
)
|
||||
}
|
||||
}
|
||||
return try await group.reduce(into: []) { $0.append($1) }
|
||||
}
|
||||
await send(.modelsLoaded(recommended: recommended, available: infos))
|
||||
} catch {
|
||||
await send(.modelsLoadFailed)
|
||||
}
|
||||
}
|
||||
|
||||
case .modelsLoadFailed:
|
||||
state.isLoadingModels = false
|
||||
return .none
|
||||
|
||||
case let .modelsLoaded(recommended, available):
|
||||
state.isLoadingModels = false
|
||||
// Ensure our curated Parakeet options are visible even if WhisperKit doesn't list them
|
||||
var availablePlus = available
|
||||
for model in ParakeetModel.allCases.reversed() {
|
||||
if !availablePlus.contains(where: { $0.name == model.identifier }) {
|
||||
availablePlus.insert(ModelInfo(name: model.identifier, isDownloaded: false), at: 0)
|
||||
}
|
||||
}
|
||||
|
||||
if availablePlus.contains(where: { $0.name == state.preferredParakeetIdentifier }) {
|
||||
state.recommendedModel = state.preferredParakeetIdentifier
|
||||
} else {
|
||||
state.recommendedModel = recommended
|
||||
}
|
||||
state.availableModels = IdentifiedArrayOf(uniqueElements: availablePlus)
|
||||
|
||||
// If the selected model is a pattern, resolve it now to the first available match
|
||||
if state.hexSettings.selectedModel.contains("*") || state.hexSettings.selectedModel.contains("?") {
|
||||
if let resolved = resolvePattern(state.hexSettings.selectedModel, from: available) {
|
||||
state.$hexSettings.withLock { $0.selectedModel = resolved }
|
||||
}
|
||||
}
|
||||
|
||||
// Merge curated + download status with pattern support
|
||||
var curated = CuratedModelLoader.load()
|
||||
for idx in curated.indices {
|
||||
let internalName = curated[idx].internalName
|
||||
if let match = available.first(where: { ModelPatternMatcher.matches(internalName, $0.name) }) {
|
||||
curated[idx].isDownloaded = match.isDownloaded
|
||||
} else {
|
||||
curated[idx].isDownloaded = false
|
||||
}
|
||||
}
|
||||
state.curatedModels = IdentifiedArrayOf(uniqueElements: curated)
|
||||
// If the selection isn't installed but another model is, switch to the
|
||||
// installed one so transcription keeps working. Never clear the user's
|
||||
// selection outright: availability scans can produce false negatives
|
||||
// (e.g. after a dependency changes its cache layout), and wiping the
|
||||
// setting turns a transient glitch into a permanent silent failure.
|
||||
if !state.selectedModelIsDownloaded,
|
||||
let installedModel = state.curatedModels.first(where: \.isDownloaded)
|
||||
{
|
||||
let fallback = resolvePattern(installedModel.internalName, from: Array(state.availableModels)) ?? installedModel.internalName
|
||||
if fallback != state.selectedModel {
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
if !state.anyModelDownloaded && !state.hexSettings.hasCompletedModelBootstrap {
|
||||
let preferred = state.recommendedModel.isEmpty ? state.hexSettings.selectedModel : state.recommendedModel
|
||||
if !preferred.isEmpty {
|
||||
state.$hexSettings.withLock { $0.selectedModel = preferred }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
}
|
||||
return .none
|
||||
|
||||
// MARK: – Download
|
||||
|
||||
case let .downloadModel(requestedModel):
|
||||
guard !requestedModel.isEmpty, !state.isDownloading else { return .none }
|
||||
// Resolve glob/legacy selections to a concrete model name up front so
|
||||
// the completion handler updates the matching rows and writes a
|
||||
// concrete name back into settings.
|
||||
let model = resolvePattern(requestedModel, from: Array(state.availableModels)) ?? requestedModel
|
||||
state.downloadError = nil
|
||||
state.isDownloading = true
|
||||
state.downloadProgress = 0
|
||||
state.downloadingModelName = model
|
||||
state.activeDownloadID = UUID()
|
||||
let downloadID = state.activeDownloadID!
|
||||
if !state.anyModelDownloaded {
|
||||
let displayName = curatedDisplayName(for: model, curated: state.curatedModels)
|
||||
state.$modelBootstrapState.withLock {
|
||||
$0.modelIdentifier = model
|
||||
$0.modelDisplayName = displayName
|
||||
$0.isModelReady = false
|
||||
$0.progress = 0
|
||||
$0.lastError = nil
|
||||
}
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.downloadModel(model) { progress in
|
||||
let fractionCompleted = progress.fractionCompleted
|
||||
Task {
|
||||
await send(.downloadProgress(id: downloadID, progress: fractionCompleted))
|
||||
}
|
||||
}
|
||||
await send(.downloadCompleted(id: downloadID, result: .success(model)))
|
||||
} catch is CancellationError {
|
||||
} catch {
|
||||
await send(.downloadCompleted(id: downloadID, result: .failure(error)))
|
||||
}
|
||||
}
|
||||
.cancellable(id: downloadID)
|
||||
|
||||
case let .downloadProgress(id, progress):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
guard state.downloadProgress != progress else { return .none }
|
||||
state.downloadProgress = progress
|
||||
if !state.modelBootstrapState.isModelReady {
|
||||
state.$modelBootstrapState.withLock { $0.progress = progress }
|
||||
}
|
||||
return .none
|
||||
|
||||
case let .downloadCompleted(id, result):
|
||||
guard state.activeDownloadID == id else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
var failureMessage: String?
|
||||
switch result {
|
||||
case let .success(name):
|
||||
state.availableModels[id: name]?.isDownloaded = true
|
||||
if let idx = state.curatedModels.firstIndex(where: { $0.internalName == name }) {
|
||||
state.curatedModels[idx].isDownloaded = true
|
||||
}
|
||||
state.$hexSettings.withLock { settings in
|
||||
settings.selectedModel = name
|
||||
settings.hasCompletedModelBootstrap = true
|
||||
}
|
||||
state.downloadError = nil
|
||||
case let .failure(err):
|
||||
let ns = err as NSError
|
||||
var message = ns.localizedDescription
|
||||
if let url = ns.userInfo[NSURLErrorFailingURLErrorKey] as? URL,
|
||||
let host = url.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
} else if let str = ns.userInfo[NSURLErrorFailingURLStringErrorKey] as? String,
|
||||
let u = URL(string: str), let host = u.host
|
||||
{
|
||||
message += " (\(host))"
|
||||
}
|
||||
state.downloadError = message
|
||||
failureMessage = message
|
||||
}
|
||||
state.$modelBootstrapState.withLock { bootstrap in
|
||||
if let failureMessage {
|
||||
bootstrap.isModelReady = false
|
||||
bootstrap.lastError = failureMessage
|
||||
bootstrap.progress = 0
|
||||
} else {
|
||||
bootstrap.isModelReady = true
|
||||
bootstrap.lastError = nil
|
||||
bootstrap.progress = 1
|
||||
}
|
||||
}
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case .cancelDownload:
|
||||
guard let id = state.activeDownloadID else { return .none }
|
||||
state.isDownloading = false
|
||||
state.downloadingModelName = nil
|
||||
state.activeDownloadID = nil
|
||||
state.downloadProgress = 0
|
||||
state.$modelBootstrapState.withLock { $0.progress = 0 }
|
||||
updateBootstrapState(&state)
|
||||
return .cancel(id: id)
|
||||
|
||||
case let .deleteModel(model):
|
||||
guard !model.isEmpty else { return .none }
|
||||
let resolved = resolvePattern(model, from: Array(state.availableModels)) ?? model
|
||||
if ModelPatternMatcher.namesMatch(model, state.selectedModel) {
|
||||
state.$modelBootstrapState.withLock { $0.isModelReady = false }
|
||||
}
|
||||
return .run { send in
|
||||
do {
|
||||
try await transcription.deleteModel(resolved)
|
||||
await send(.modelDeleted(resolved))
|
||||
} catch {
|
||||
await send(.modelDeletionFailed(error))
|
||||
}
|
||||
}
|
||||
|
||||
case let .modelDeleted(model):
|
||||
state.availableModels[id: model]?.isDownloaded = false
|
||||
for index in state.curatedModels.indices
|
||||
where ModelPatternMatcher.matches(state.curatedModels[index].internalName, model) {
|
||||
state.curatedModels[index].isDownloaded = false
|
||||
}
|
||||
if ModelPatternMatcher.namesMatch(state.selectedModel, model) {
|
||||
let fallback = state.availableModels.first { $0.isDownloaded }?.name ?? ""
|
||||
state.$hexSettings.withLock { $0.selectedModel = fallback }
|
||||
updateBootstrapState(&state)
|
||||
}
|
||||
return .send(.fetchModels)
|
||||
|
||||
case let .modelDeletionFailed(error):
|
||||
state.downloadError = error.localizedDescription
|
||||
updateBootstrapState(&state)
|
||||
return .none
|
||||
|
||||
case let .openModelLocation(model):
|
||||
return openModelLocationEffect(for: model)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
private func openModelLocationEffect(for model: String) -> Effect<Action> {
|
||||
// Parakeet caches live under FluidAudio's directory, not the WhisperKit
|
||||
// models folder. Route "Show in Finder" to the matching root so users
|
||||
// don't end up staring at an empty WhisperKit folder thinking the
|
||||
// Parakeet download silently failed.
|
||||
let usesParakeetRoot = ParakeetModel(rawValue: model) != nil
|
||||
return .run { _ in
|
||||
let base = try usesParakeetRoot
|
||||
? URL.hexParakeetModelsDirectory
|
||||
: URL.hexModelsDirectory
|
||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: base.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ModelDownloadFeature.State {
|
||||
var preferredParakeetIdentifier: String {
|
||||
(prefersEnglishParakeet ? ParakeetModel.englishV2 : ParakeetModel.multilingualV3).identifier
|
||||
}
|
||||
|
||||
private var prefersEnglishParakeet: Bool {
|
||||
guard let language = hexSettings.outputLanguage?.lowercased(), !language.isEmpty else {
|
||||
return true
|
||||
}
|
||||
return language.hasPrefix("en")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,606 @@
|
||||
import ComposableArchitecture
|
||||
import HexCore
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
private func modelNamesMatch(_ lhs: String, _ rhs: String) -> Bool {
|
||||
ModelPatternMatcher.namesMatch(lhs, rhs)
|
||||
}
|
||||
|
||||
public struct ModelDownloadView: View {
|
||||
@ObserveInjection var inject
|
||||
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@State private var isModelLibraryPresented = false
|
||||
var shouldFlash: Bool = false
|
||||
|
||||
public init(store: StoreOf<ModelDownloadFeature>, shouldFlash: Bool = false) {
|
||||
self.store = store
|
||||
self.shouldFlash = shouldFlash
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if !store.modelBootstrapState.isModelReady,
|
||||
let message = store.modelBootstrapState.lastError,
|
||||
!message.isEmpty
|
||||
{
|
||||
AutoDownloadBannerView(
|
||||
title: "Download failed",
|
||||
subtitle: message,
|
||||
progress: nil,
|
||||
style: .error
|
||||
)
|
||||
}
|
||||
if selectedModelName == nil, downloadingModel == nil {
|
||||
NoModelChooser(
|
||||
models: parakeetModels,
|
||||
suggestedModel: suggestedParakeetIdentifier,
|
||||
isLoading: store.isLoadingModels,
|
||||
isFlashing: shouldFlash,
|
||||
onDownload: { store.send(.downloadModel($0.internalName)) },
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onRetry: { store.send(.fetchModels) }
|
||||
)
|
||||
} else {
|
||||
CurrentModelSummary(
|
||||
model: selectedModel,
|
||||
selectedModelName: selectedModelName,
|
||||
isInstalled: store.selectedModelIsDownloaded,
|
||||
isDownloadingAnything: store.isDownloading,
|
||||
downloadingModel: downloadingModel,
|
||||
downloadProgress: store.downloadProgress,
|
||||
onBrowse: { isModelLibraryPresented = true },
|
||||
onDownload: {
|
||||
if let name = selectedModelName {
|
||||
store.send(.downloadModel(name))
|
||||
}
|
||||
},
|
||||
onCancelDownload: { store.send(.cancelDownload) }
|
||||
)
|
||||
}
|
||||
if let err = store.downloadError,
|
||||
err != store.modelBootstrapState.lastError
|
||||
{
|
||||
Text("Model Error: \(err)")
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.sheet(isPresented: $isModelLibraryPresented) {
|
||||
ModelLibrarySheet(store: store)
|
||||
}
|
||||
.task {
|
||||
if store.availableModels.isEmpty {
|
||||
store.send(.fetchModels)
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
|
||||
private var selectedModel: CuratedModelInfo? {
|
||||
guard let selectedModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, selectedModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedModelName: String? {
|
||||
store.selectedModelNameForDisplay
|
||||
}
|
||||
|
||||
private var downloadingModel: CuratedModelInfo? {
|
||||
guard let downloadingModelName = store.downloadingModelName else { return nil }
|
||||
return store.curatedModels.first { model in
|
||||
modelNamesMatch(model.internalName, downloadingModelName)
|
||||
}
|
||||
}
|
||||
|
||||
private var parakeetModels: [CuratedModelInfo] {
|
||||
let models = store.curatedModels.filter(\.isParakeet)
|
||||
return models.filter { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } +
|
||||
models.filter { !modelNamesMatch($0.internalName, suggestedParakeetIdentifier) }
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelChooser: View {
|
||||
let models: [CuratedModelInfo]
|
||||
let suggestedModel: String
|
||||
let isLoading: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: (CuratedModelInfo) -> Void
|
||||
let onBrowse: () -> Void
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Choose a transcription model")
|
||||
.font(.body.weight(.medium))
|
||||
Text("Download a local model to start transcribing.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("View All Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
|
||||
if models.isEmpty {
|
||||
if isLoading {
|
||||
Text("Loading model choices…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
HStack {
|
||||
Text("Model choices couldn’t be loaded.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Retry", action: onRetry)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(models) { model in
|
||||
NoModelCard(
|
||||
model: model,
|
||||
isRecommended: matches(model),
|
||||
isFlashing: isFlashing && matches(model),
|
||||
onDownload: { onDownload(model) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
|
||||
private func matches(_ model: CuratedModelInfo) -> Bool {
|
||||
modelNamesMatch(model.internalName, suggestedModel)
|
||||
}
|
||||
}
|
||||
|
||||
private struct NoModelCard: View {
|
||||
let model: CuratedModelInfo
|
||||
let isRecommended: Bool
|
||||
let isFlashing: Bool
|
||||
let onDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onDownload) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer(minLength: 8)
|
||||
if isRecommended {
|
||||
Text("Suggested")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(model.size)
|
||||
Text("·")
|
||||
Text(model.storageSize)
|
||||
Spacer()
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color(NSColor.windowBackgroundColor).opacity(0.45), in: RoundedRectangle(cornerRadius: 8))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(borderColor, lineWidth: isFlashing ? 3 : 1)
|
||||
.animation(.easeInOut(duration: 0.5).repeatCount(3, autoreverses: true), value: isFlashing)
|
||||
)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isFlashing { return Color.accentColor }
|
||||
if isRecommended { return Color.accentColor.opacity(0.45) }
|
||||
return Color.primary.opacity(0.08)
|
||||
}
|
||||
}
|
||||
|
||||
private struct CurrentModelSummary: View {
|
||||
let model: CuratedModelInfo?
|
||||
let selectedModelName: String?
|
||||
let isInstalled: Bool
|
||||
let isDownloadingAnything: Bool
|
||||
let downloadingModel: CuratedModelInfo?
|
||||
let downloadProgress: Double
|
||||
let onBrowse: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.accentColor)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(title)
|
||||
.font(.body.weight(.medium))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(needsDownload ? Color.orange : Color.secondary)
|
||||
if let activeDownloadStatus {
|
||||
Text(activeDownloadStatus)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if isDownloadingAnything {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.linear)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isDownloadingAnything {
|
||||
VStack(alignment: .trailing, spacing: 4) {
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
if needsDownload {
|
||||
Button("Download", action: onDownload)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
Button("Browse Models…", action: onBrowse)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
/// The selection references a model that isn't on disk (e.g. the scan came
|
||||
/// back empty after an update). Offer a direct download instead of
|
||||
/// pretending it's installed.
|
||||
private var needsDownload: Bool {
|
||||
selectedModelName != nil && !isInstalled && !isDownloadingAnything
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
if let model { return model.displayName }
|
||||
if let selectedModelName {
|
||||
return selectedModelName
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.capitalized
|
||||
}
|
||||
if let downloadingModel { return "Downloading \(downloadingModel.displayName)…" }
|
||||
return "Choose a transcription model"
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
if needsDownload { return "Not downloaded — transcription won't work until you download it." }
|
||||
if let model { return "\(model.size) · \(model.storageSize)" }
|
||||
if selectedModelName != nil { return "Installed local model" }
|
||||
if let downloadingModel { return "\(downloadingModel.storageSize) will be stored locally on this Mac." }
|
||||
return "Download a local model to start transcribing."
|
||||
}
|
||||
|
||||
private var activeDownloadStatus: String? {
|
||||
guard selectedModelName != nil, let downloadingModel else { return nil }
|
||||
return "Downloading \(downloadingModel.displayName)…"
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
if needsDownload { return "exclamationmark.triangle.fill" }
|
||||
if selectedModelName == nil { return "arrow.down.circle.fill" }
|
||||
return "waveform"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct ModelLibrarySheet: View {
|
||||
@Bindable var store: StoreOf<ModelDownloadFeature>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var pendingDelete: CuratedModelInfo?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model Library")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Select an installed model to use it, or download another model for local transcription.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
if let recommendedLibraryModel {
|
||||
modelSection(title: "Recommended", models: [recommendedLibraryModel], showsBadges: false)
|
||||
}
|
||||
if !otherLibraryModels.isEmpty {
|
||||
modelSection(title: "Other Models", models: otherLibraryModels, showsBadges: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Label("Models run locally on your Mac. Downloads are stored on this device.", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(18)
|
||||
.frame(minWidth: 680, minHeight: 420)
|
||||
.confirmationDialog(
|
||||
"Remove \(pendingDelete?.displayName ?? "model")?",
|
||||
isPresented: Binding(
|
||||
get: { pendingDelete != nil },
|
||||
set: { if !$0 { pendingDelete = nil } }
|
||||
),
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
if let pendingDelete {
|
||||
Button("Remove Download", role: .destructive) {
|
||||
store.send(.deleteModel(pendingDelete.internalName))
|
||||
self.pendingDelete = nil
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { pendingDelete = nil }
|
||||
} message: {
|
||||
if let pendingDelete {
|
||||
Text("This frees \(pendingDelete.storageSize) on this Mac. You can download it again anytime.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func select(_ model: CuratedModelInfo) {
|
||||
guard model.isDownloaded, !store.isDownloading else { return }
|
||||
store.send(.selectModel(model.internalName))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func modelSection(title: String, models: [CuratedModelInfo], showsBadges: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 2)
|
||||
VStack(spacing: 0) {
|
||||
ForEach(models) { model in
|
||||
ModelLibraryRow(
|
||||
model: model,
|
||||
isSelected: model.isDownloaded && isSelected(model),
|
||||
isDownloading: isDownloading(model),
|
||||
// Only the active row receives live progress so the other
|
||||
// rows don't re-render on every tick.
|
||||
downloadProgress: isDownloading(model) ? store.downloadProgress : 0,
|
||||
isDisabled: store.isDownloading && !isDownloading(model),
|
||||
showsBadge: showsBadges && !model.isDownloaded,
|
||||
onSelect: { select(model) },
|
||||
onDownload: { store.send(.downloadModel(model.internalName)) },
|
||||
onCancelDownload: { store.send(.cancelDownload) },
|
||||
onShowInFinder: { store.send(.openModelLocation(model.internalName)) },
|
||||
onDelete: { pendingDelete = model }
|
||||
)
|
||||
if model.id != models.last?.id {
|
||||
Divider().padding(.leading, 54)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(NSColor.controlBackgroundColor), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
private var recommendedLibraryModel: CuratedModelInfo? {
|
||||
store.curatedModels.first { modelNamesMatch($0.internalName, suggestedParakeetIdentifier) } ?? store.curatedModels.first(where: \.isParakeet)
|
||||
}
|
||||
|
||||
private var otherLibraryModels: [CuratedModelInfo] {
|
||||
guard let recommendedLibraryModel else { return Array(store.curatedModels) }
|
||||
return store.curatedModels.filter { $0.id != recommendedLibraryModel.id }
|
||||
}
|
||||
|
||||
private func isSelected(_ model: CuratedModelInfo) -> Bool {
|
||||
let selected = store.hexSettings.selectedModel
|
||||
return modelNamesMatch(model.internalName, selected)
|
||||
}
|
||||
|
||||
private func isDownloading(_ model: CuratedModelInfo) -> Bool {
|
||||
store.isDownloading && store.downloadingModelName == model.internalName
|
||||
}
|
||||
|
||||
private var suggestedParakeetIdentifier: String {
|
||||
store.preferredParakeetIdentifier
|
||||
}
|
||||
}
|
||||
|
||||
private struct ModelLibraryRow: View {
|
||||
let model: CuratedModelInfo
|
||||
let isSelected: Bool
|
||||
let isDownloading: Bool
|
||||
let downloadProgress: Double
|
||||
let isDisabled: Bool
|
||||
let showsBadge: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDownload: () -> Void
|
||||
let onCancelDownload: () -> Void
|
||||
let onShowInFinder: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Button(action: onSelect) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: leadingIcon)
|
||||
.foregroundStyle(leadingColor)
|
||||
.font(.body)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 7) {
|
||||
Text(model.displayName)
|
||||
.font(.body.weight(.medium))
|
||||
if showsBadge, let badge = model.badge {
|
||||
Text(badge)
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
HStack(spacing: 12) {
|
||||
Text(model.size)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 5) {
|
||||
Text("Accuracy").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.accuracyStars)
|
||||
}
|
||||
HStack(spacing: 5) {
|
||||
Text("Speed").font(.caption2).foregroundStyle(.secondary)
|
||||
StarRatingView(model.speedStars)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
Text(model.storageSize)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if let statusText {
|
||||
Text(statusText)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
trailingControls
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
.disabled(isDisabled)
|
||||
.opacity(isDisabled ? 0.55 : 1)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.06) : Color.clear)
|
||||
.contextMenu {
|
||||
if isDownloading {
|
||||
Button("Cancel Download", role: .destructive, action: onCancelDownload)
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
}
|
||||
if model.isDownloaded {
|
||||
managementMenuItems
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared by the trailing ⋯ menu and the right-click context menu.
|
||||
@ViewBuilder
|
||||
private var managementMenuItems: some View {
|
||||
Button("Show in Finder", action: onShowInFinder)
|
||||
Divider()
|
||||
Button("Remove Download…", role: .destructive, action: onDelete)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var trailingControls: some View {
|
||||
if isDownloading {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: downloadProgress)
|
||||
.progressViewStyle(.circular)
|
||||
.controlSize(.small)
|
||||
Text("\(Int(downloadProgress * 100))%")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
Button("Cancel", role: .destructive, action: onCancelDownload)
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if model.isDownloaded {
|
||||
Menu {
|
||||
managementMenuItems
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.menuIndicator(.hidden)
|
||||
.fixedSize()
|
||||
.help("Show in Finder or remove this download")
|
||||
} else {
|
||||
Button("Download", action: onDownload)
|
||||
.controlSize(.small)
|
||||
.help("Download \(model.storageSize) and switch to this model")
|
||||
}
|
||||
}
|
||||
|
||||
private var statusText: String? {
|
||||
if isDownloading { return "Downloading…" }
|
||||
if isSelected { return "In Use" }
|
||||
if model.isDownloaded { return "Installed" }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var leadingIcon: String {
|
||||
if isDownloading { return "arrow.down.circle.fill" }
|
||||
if isSelected { return "checkmark.circle.fill" }
|
||||
if model.isDownloaded { return "circle" }
|
||||
return "arrow.down.circle"
|
||||
}
|
||||
|
||||
private var leadingColor: Color {
|
||||
if isDownloading || isSelected { return .accentColor }
|
||||
return .secondary
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Inject
|
||||
import SwiftUI
|
||||
|
||||
struct StarRatingView: View {
|
||||
@ObserveInjection var inject
|
||||
let filled: Int
|
||||
let max: Int
|
||||
|
||||
init(_ filled: Int, max: Int = 5) {
|
||||
self.filled = filled
|
||||
self.max = max
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0 ..< max, id: \.self) { i in
|
||||
Image(systemName: i < filled ? "circle.fill" : "circle")
|
||||
.font(.system(size: 7))
|
||||
.foregroundColor(i < filled ? .blue : .gray.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.enableInjection()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user