import SwiftUI import SwiftData import AppKit import UniformTypeIdentifiers enum ModelFilter: String, CaseIterable, Identifiable { case recommended = "Recommended" case local = "Local" case cloud = "Cloud" case custom = "Custom" var id: String { self.rawValue } } struct ModelManagementView: View { @ObservedObject var whisperState: WhisperState @State private var customModelToEdit: CustomCloudModel? @StateObject private var aiService = AIService() @StateObject private var customModelManager = CustomModelManager.shared @EnvironmentObject private var enhancementService: AIEnhancementService @Environment(\.modelContext) private var modelContext @StateObject private var whisperPrompt = WhisperPrompt() @State private var selectedFilter: ModelFilter = .recommended @State private var isShowingSettings = false // State for the unified alert @State private var isShowingDeleteAlert = false @State private var alertTitle = "" @State private var alertMessage = "" @State private var deleteActionClosure: () -> Void = {} var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { defaultModelSection languageSelectionSection availableModelsSection } .padding(40) } .frame(minWidth: 600, minHeight: 500) .background(Color(NSColor.controlBackgroundColor)) .alert(isPresented: $isShowingDeleteAlert) { Alert( title: Text(alertTitle), message: Text(alertMessage), primaryButton: .destructive(Text("Delete"), action: deleteActionClosure), secondaryButton: .cancel() ) } } private var defaultModelSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Default Model") .font(.headline) .foregroundColor(.secondary) Text(whisperState.currentTranscriptionModel?.displayName ?? "No model selected") .font(.title2) .fontWeight(.bold) } .padding() .frame(maxWidth: .infinity, alignment: .leading) .background(CardBackground(isSelected: false)) .cornerRadius(10) } private var languageSelectionSection: some View { LanguageSelectionView(whisperState: whisperState, displayMode: .full, whisperPrompt: whisperPrompt) } private var availableModelsSection: some View { VStack(alignment: .leading, spacing: 16) { HStack { // Modern compact pill switcher HStack(spacing: 12) { ForEach(ModelFilter.allCases, id: \.self) { filter in Button(action: { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { selectedFilter = filter isShowingSettings = false } }) { Text(filter.rawValue) .font(.system(size: 14, weight: selectedFilter == filter ? .semibold : .medium)) .foregroundColor(selectedFilter == filter ? .primary : .primary.opacity(0.7)) .padding(.horizontal, 16) .padding(.vertical, 8) .background( CardBackground(isSelected: selectedFilter == filter, cornerRadius: 22) ) } .buttonStyle(PlainButtonStyle()) } } Spacer() Button(action: { withAnimation(.easeInOut(duration: 0.2)) { isShowingSettings.toggle() } }) { Image(systemName: "gear") .font(.system(size: 16, weight: .medium)) .foregroundColor(isShowingSettings ? .accentColor : .primary.opacity(0.7)) .padding(12) .background( CardBackground(isSelected: isShowingSettings, cornerRadius: 22) ) } .buttonStyle(PlainButtonStyle()) } .padding(.bottom, 12) if isShowingSettings { ModelSettingsView(whisperPrompt: whisperPrompt) } else { VStack(spacing: 12) { ForEach(filteredModels, id: \.id) { model in ModelCardRowView( model: model, whisperState: whisperState, isDownloaded: whisperState.availableModels.contains { $0.name == model.name }, isCurrent: whisperState.currentTranscriptionModel?.name == model.name, downloadProgress: whisperState.downloadProgress, modelURL: whisperState.availableModels.first { $0.name == model.name }?.url, deleteAction: { if let customModel = model as? CustomCloudModel { alertTitle = "Delete Custom Model" alertMessage = "Are you sure you want to delete the custom model '\(customModel.displayName)'?" deleteActionClosure = { customModelManager.removeCustomModel(withId: customModel.id) whisperState.refreshAllAvailableModels() } isShowingDeleteAlert = true } else if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) { alertTitle = "Delete Model" alertMessage = "Are you sure you want to delete the model '\(downloadedModel.name)'?" deleteActionClosure = { Task { await whisperState.deleteModel(downloadedModel) } } isShowingDeleteAlert = true } }, setDefaultAction: { Task { await whisperState.setDefaultTranscriptionModel(model) } }, downloadAction: { if let localModel = model as? LocalModel { Task { await whisperState.downloadModel(localModel) } } }, editAction: model.provider == .custom ? { customModel in customModelToEdit = customModel } : nil ) } // Import button as a card at the end of the Local list if selectedFilter == .local { HStack(spacing: 8) { Button(action: { presentImportPanel() }) { HStack(spacing: 8) { Image(systemName: "square.and.arrow.down") Text("Import Local Model…") .font(.system(size: 12, weight: .semibold)) } .frame(maxWidth: .infinity) .padding(16) .background(CardBackground(isSelected: false)) .cornerRadius(10) } .buttonStyle(.plain) InfoTip( title: "Import local Whisper models", message: "Add a custom fine-tuned whisper model to use with VoiceInk. Select the downloaded .bin file.", learnMoreURL: "https://tryvoiceink.com/docs/custom-local-whisper-models" ) .help("Read more about custom local models") } } if selectedFilter == .custom { // Add Custom Model Card at the bottom AddCustomModelCardView( customModelManager: customModelManager, editingModel: customModelToEdit ) { // Refresh the models when a new custom model is added whisperState.refreshAllAvailableModels() customModelToEdit = nil // Clear editing state } } } } } .padding() } private var filteredModels: [any TranscriptionModel] { switch selectedFilter { case .recommended: return whisperState.allAvailableModels.filter { let recommendedNames = ["ggml-base.en", "ggml-large-v3-turbo-q5_0", "ggml-large-v3-turbo", "whisper-large-v3-turbo"] return recommendedNames.contains($0.name) }.sorted { model1, model2 in let recommendedOrder = ["ggml-base.en", "ggml-large-v3-turbo-q5_0", "ggml-large-v3-turbo", "whisper-large-v3-turbo"] let index1 = recommendedOrder.firstIndex(of: model1.name) ?? Int.max let index2 = recommendedOrder.firstIndex(of: model2.name) ?? Int.max return index1 < index2 } case .local: return whisperState.allAvailableModels.filter { $0.provider == .local || $0.provider == .nativeApple || $0.provider == .parakeet } case .cloud: let cloudProviders: [ModelProvider] = [.groq, .elevenLabs, .deepgram, .mistral] return whisperState.allAvailableModels.filter { cloudProviders.contains($0.provider) } case .custom: return whisperState.allAvailableModels.filter { $0.provider == .custom } } } // MARK: - Import Panel private func presentImportPanel() { let panel = NSOpenPanel() panel.allowedContentTypes = [.init(filenameExtension: "bin")!] panel.allowsMultipleSelection = false panel.canChooseDirectories = false panel.resolvesAliases = true panel.title = "Select a Whisper ggml .bin model" if panel.runModal() == .OK, let url = panel.url { Task { @MainActor in await whisperState.importLocalModel(from: url) } } } }