Files
gart/code/Hex-main/Hex/Features/Settings/ModelDownload/ModelDownloadView.swift
T

607 lines
17 KiB
Swift
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}