chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
@@ -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()
}
}