Redesign AI model management with modern filter UI

This commit is contained in:
Beingpax 2025-07-08 07:59:38 +05:45
parent a519934232
commit fe19b60747
7 changed files with 915 additions and 841 deletions

View File

@ -0,0 +1,322 @@
import SwiftUI
import AppKit
// MARK: - Cloud Model Card View
struct CloudModelCardView: View {
let model: CloudModel
let isCurrent: Bool
var setDefaultAction: () -> Void
@EnvironmentObject private var whisperState: WhisperState
@StateObject private var aiService = AIService()
@State private var isExpanded = false
@State private var apiKey = ""
@State private var isVerifying = false
@State private var verificationStatus: VerificationStatus = .none
@State private var isConfiguredState: Bool = false
enum VerificationStatus {
case none, verifying, success, failure
}
private var isConfigured: Bool {
guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else {
return false
}
return !savedKey.isEmpty
}
private var providerKey: String {
switch model.provider {
case .groq:
return "GROQ"
case .elevenLabs:
return "ElevenLabs"
case .deepgram:
return "Deepgram"
default:
return model.provider.rawValue
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main card content
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
}
.frame(maxWidth: .infinity, alignment: .leading)
actionSection
}
.padding(16)
// Expandable configuration section
if isExpanded {
Divider()
.padding(.horizontal, 16)
configurationSection
.padding(16)
}
}
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
.onAppear {
loadSavedAPIKey()
isConfiguredState = isConfigured
}
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else if isConfiguredState {
Text("Configured")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.systemGreen).opacity(0.2)))
.foregroundColor(Color(.systemGreen))
} else {
Text("Setup Required")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.systemOrange).opacity(0.2)))
.foregroundColor(Color(.systemOrange))
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Provider
Label(model.provider.rawValue, systemImage: "cloud")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
Label("Cloud Model", systemImage: "icloud")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Accuracy
HStack(spacing: 3) {
Text("Accuracy")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(.secondaryLabelColor))
progressDotsWithNumber(value: model.accuracy * 10)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else if isConfiguredState {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Button(action: {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded.toggle()
}
}) {
HStack(spacing: 4) {
Text("Configure")
.font(.system(size: 12, weight: .medium))
Image(systemName: "gear")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color(.controlAccentColor))
.shadow(color: Color(.controlAccentColor).opacity(0.2), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(.plain)
}
if isConfiguredState {
Menu {
Button {
clearAPIKey()
} label: {
Label("Remove API Key", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 20, height: 20)
}
}
}
private var configurationSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("API Key Configuration")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
HStack(spacing: 8) {
SecureField("Enter your \(model.provider.rawValue) API key", text: $apiKey)
.textFieldStyle(.roundedBorder)
.disabled(isVerifying)
Button(action: verifyAPIKey) {
HStack(spacing: 4) {
if isVerifying {
ProgressView()
.scaleEffect(0.7)
.frame(width: 12, height: 12)
} else {
Image(systemName: verificationStatus == .success ? "checkmark" : "checkmark.shield")
.font(.system(size: 12, weight: .medium))
}
Text(isVerifying ? "Verifying..." : "Verify")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(verificationStatus == .success ? Color(.systemGreen) : Color(.controlAccentColor))
)
}
.buttonStyle(.plain)
.disabled(apiKey.isEmpty || isVerifying)
}
if verificationStatus == .failure {
Text("Invalid API key. Please check your key and try again.")
.font(.caption)
.foregroundColor(Color(.systemRed))
} else if verificationStatus == .success {
Text("API key verified successfully!")
.font(.caption)
.foregroundColor(Color(.systemGreen))
}
}
}
private func loadSavedAPIKey() {
if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") {
apiKey = savedKey
verificationStatus = .success
}
}
private func verifyAPIKey() {
guard !apiKey.isEmpty else { return }
isVerifying = true
verificationStatus = .verifying
// Set the provider in AIService temporarily for verification
let originalProvider = aiService.selectedProvider
if model.provider == .groq {
aiService.selectedProvider = .groq
} else if model.provider == .elevenLabs {
aiService.selectedProvider = .elevenLabs
} else if model.provider == .deepgram {
aiService.selectedProvider = .deepgram
}
aiService.verifyAPIKey(apiKey) { [self] isValid in
DispatchQueue.main.async {
self.isVerifying = false
if isValid {
self.verificationStatus = .success
// Save the API key
UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey")
self.isConfiguredState = true
// Collapse the configuration section after successful verification
withAnimation(.easeInOut(duration: 0.3)) {
self.isExpanded = false
}
} else {
self.verificationStatus = .failure
}
// Restore original provider
aiService.selectedProvider = originalProvider
}
}
}
private func clearAPIKey() {
UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey")
apiKey = ""
verificationStatus = .none
isConfiguredState = false
// If this model is currently the default, clear it
if isCurrent {
Task {
await MainActor.run {
whisperState.currentTranscriptionModel = nil
UserDefaults.standard.removeObject(forKey: "CurrentTranscriptionModel")
}
}
}
withAnimation(.easeInOut(duration: 0.3)) {
isExpanded = false
}
}
}

View File

@ -0,0 +1,130 @@
import SwiftUI
import AppKit
// MARK: - Custom Model Card View
struct CustomModelCardView: View {
let model: CustomCloudModel
let isCurrent: Bool
var setDefaultAction: () -> Void
var deleteAction: () -> Void
var editAction: (CustomCloudModel) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main card content
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
}
.frame(maxWidth: .infinity, alignment: .leading)
actionSection
}
.padding(16)
}
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else {
Text("Custom")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.orange.opacity(0.2)))
.foregroundColor(Color.orange)
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Provider
Label("Custom Provider", systemImage: "cloud")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// OpenAI Compatible
Label("OpenAI Compatible", systemImage: "checkmark.seal")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Menu {
Button {
editAction(model)
} label: {
Label("Edit Model", systemImage: "pencil")
}
Button(role: .destructive) {
deleteAction()
} label: {
Label("Delete Model", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 20, height: 20)
}
}
}

View File

@ -58,9 +58,6 @@ struct LanguageSelectionView: View {
private var fullView: some View {
VStack(alignment: .leading, spacing: 16) {
languageSelectionSection
// Add prompt customization view below language selection
PromptCustomizationView(whisperPrompt: whisperPrompt)
}
}

View File

@ -0,0 +1,218 @@
import SwiftUI
import AppKit
// MARK: - Local Model Card View
struct LocalModelCardView: View {
let model: LocalModel
let isDownloaded: Bool
let isCurrent: Bool
let downloadProgress: [String: Double]
let modelURL: URL?
// Actions
var deleteAction: () -> Void
var setDefaultAction: () -> Void
var downloadAction: () -> Void
private var isDownloading: Bool {
downloadProgress.keys.contains(model.name + "_main") ||
downloadProgress.keys.contains(model.name + "_coreml")
}
var body: some View {
HStack(alignment: .top, spacing: 16) {
// Main Content
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
progressSection
}
.frame(maxWidth: .infinity, alignment: .leading)
// Action Controls
actionSection
}
.padding(16)
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else if isDownloaded {
Text("Downloaded")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.quaternaryLabelColor)))
.foregroundColor(Color(.labelColor))
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Size
Label(model.size, systemImage: "internaldrive")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Speed
HStack(spacing: 3) {
Text("Speed")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(.secondaryLabelColor))
progressDotsWithNumber(value: model.speed * 10)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
// Accuracy
HStack(spacing: 3) {
Text("Accuracy")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(.secondaryLabelColor))
progressDotsWithNumber(value: model.accuracy * 10)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var progressSection: some View {
Group {
if isDownloading {
DownloadProgressView(
modelName: model.name,
downloadProgress: downloadProgress
)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else if isDownloaded {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Button(action: downloadAction) {
HStack(spacing: 4) {
Text(isDownloading ? "Downloading..." : "Download")
.font(.system(size: 12, weight: .medium))
Image(systemName: "arrow.down.circle")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color(.controlAccentColor))
.shadow(color: Color(.controlAccentColor).opacity(0.2), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(.plain)
.disabled(isDownloading)
}
if isDownloaded {
Menu {
Button(action: deleteAction) {
Label("Delete Model", systemImage: "trash")
}
Button {
if let modelURL = modelURL {
NSWorkspace.shared.selectFile(modelURL.path, inFileViewerRootedAtPath: "")
}
} label: {
Label("Show in Finder", systemImage: "folder")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 20, height: 20)
}
}
}
}
// MARK: - Helper Views and Functions
func progressDotsWithNumber(value: Double) -> some View {
HStack(spacing: 4) {
progressDots(value: value)
Text(String(format: "%.1f", value))
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(Color(.secondaryLabelColor))
}
}
func progressDots(value: Double) -> some View {
HStack(spacing: 2) {
ForEach(0..<5) { index in
Circle()
.fill(index < Int(value / 2) ? performanceColor(value: value / 10) : Color(.quaternaryLabelColor))
.frame(width: 6, height: 6)
}
}
}
func performanceColor(value: Double) -> Color {
switch value {
case 0.8...1.0: return Color(.systemGreen)
case 0.6..<0.8: return Color(.systemYellow)
case 0.4..<0.6: return Color(.systemOrange)
default: return Color(.systemRed)
}
}

View File

@ -59,779 +59,4 @@ struct ModelCardRowView: View {
}
}
}
}
// MARK: - Local Model Card View
struct LocalModelCardView: View {
let model: LocalModel
let isDownloaded: Bool
let isCurrent: Bool
let downloadProgress: [String: Double]
let modelURL: URL?
// Actions
var deleteAction: () -> Void
var setDefaultAction: () -> Void
var downloadAction: () -> Void
private var isDownloading: Bool {
downloadProgress.keys.contains(model.name + "_main") ||
downloadProgress.keys.contains(model.name + "_coreml")
}
var body: some View {
HStack(alignment: .top, spacing: 16) {
// Main Content
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
progressSection
}
.frame(maxWidth: .infinity, alignment: .leading)
// Action Controls
actionSection
}
.padding(16)
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else if isDownloaded {
Text("Downloaded")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.quaternaryLabelColor)))
.foregroundColor(Color(.labelColor))
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Size
Label(model.size, systemImage: "internaldrive")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Speed
HStack(spacing: 3) {
Text("Speed")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(.secondaryLabelColor))
progressDotsWithNumber(value: model.speed * 10)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
// Accuracy
HStack(spacing: 3) {
Text("Accuracy")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(.secondaryLabelColor))
progressDotsWithNumber(value: model.accuracy * 10)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var progressSection: some View {
Group {
if isDownloading {
DownloadProgressView(
modelName: model.name,
downloadProgress: downloadProgress
)
.padding(.top, 8)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else if isDownloaded {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Button(action: downloadAction) {
HStack(spacing: 4) {
Text(isDownloading ? "Downloading..." : "Download")
.font(.system(size: 12, weight: .medium))
Image(systemName: "arrow.down.circle")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color(.controlAccentColor))
.shadow(color: Color(.controlAccentColor).opacity(0.2), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(.plain)
.disabled(isDownloading)
}
if isDownloaded {
Menu {
Button(action: deleteAction) {
Label("Delete Model", systemImage: "trash")
}
Button {
if let modelURL = modelURL {
NSWorkspace.shared.selectFile(modelURL.path, inFileViewerRootedAtPath: "")
}
} label: {
Label("Show in Finder", systemImage: "folder")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 20, height: 20)
}
}
}
}
// MARK: - Cloud Model Card View
struct CloudModelCardView: View {
let model: CloudModel
let isCurrent: Bool
var setDefaultAction: () -> Void
@EnvironmentObject private var whisperState: WhisperState
@StateObject private var aiService = AIService()
@State private var isExpanded = false
@State private var apiKey = ""
@State private var isVerifying = false
@State private var verificationStatus: VerificationStatus = .none
@State private var isConfiguredState: Bool = false
enum VerificationStatus {
case none, verifying, success, failure
}
private var isConfigured: Bool {
guard let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") else {
return false
}
return !savedKey.isEmpty
}
private var providerKey: String {
switch model.provider {
case .groq:
return "GROQ"
case .elevenLabs:
return "ElevenLabs"
case .deepgram:
return "Deepgram"
default:
return model.provider.rawValue
}
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main card content
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
}
.frame(maxWidth: .infinity, alignment: .leading)
actionSection
}
.padding(16)
// Expandable configuration section
if isExpanded {
Divider()
.padding(.horizontal, 16)
configurationSection
.padding(16)
}
}
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
.onAppear {
loadSavedAPIKey()
isConfiguredState = isConfigured
}
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else if isConfiguredState {
Text("Configured")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.systemGreen).opacity(0.2)))
.foregroundColor(Color(.systemGreen))
} else {
Text("Setup Required")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color(.systemOrange).opacity(0.2)))
.foregroundColor(Color(.systemOrange))
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Provider
Label(model.provider.rawValue, systemImage: "cloud")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
Label("Cloud Model", systemImage: "icloud")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Accuracy
HStack(spacing: 3) {
Text("Accuracy")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(.secondaryLabelColor))
progressDotsWithNumber(value: model.accuracy * 10)
}
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else if isConfiguredState {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
} else {
Button(action: {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded.toggle()
}
}) {
HStack(spacing: 4) {
Text("Configure")
.font(.system(size: 12, weight: .medium))
Image(systemName: "gear")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(Color(.controlAccentColor))
.shadow(color: Color(.controlAccentColor).opacity(0.2), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(.plain)
}
if isConfiguredState {
Menu {
Button {
clearAPIKey()
} label: {
Label("Remove API Key", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 20, height: 20)
}
}
}
private var configurationSection: some View {
VStack(alignment: .leading, spacing: 12) {
Text("API Key Configuration")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
HStack(spacing: 8) {
SecureField("Enter your \(model.provider.rawValue) API key", text: $apiKey)
.textFieldStyle(.roundedBorder)
.disabled(isVerifying)
Button(action: verifyAPIKey) {
HStack(spacing: 4) {
if isVerifying {
ProgressView()
.scaleEffect(0.7)
.frame(width: 12, height: 12)
} else {
Image(systemName: verificationStatus == .success ? "checkmark" : "checkmark.shield")
.font(.system(size: 12, weight: .medium))
}
Text(isVerifying ? "Verifying..." : "Verify")
.font(.system(size: 12, weight: .medium))
}
.foregroundColor(.white)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(verificationStatus == .success ? Color(.systemGreen) : Color(.controlAccentColor))
)
}
.buttonStyle(.plain)
.disabled(apiKey.isEmpty || isVerifying)
}
if verificationStatus == .failure {
Text("Invalid API key. Please check your key and try again.")
.font(.caption)
.foregroundColor(Color(.systemRed))
} else if verificationStatus == .success {
Text("API key verified successfully!")
.font(.caption)
.foregroundColor(Color(.systemGreen))
}
}
}
private func loadSavedAPIKey() {
if let savedKey = UserDefaults.standard.string(forKey: "\(providerKey)APIKey") {
apiKey = savedKey
verificationStatus = .success
}
}
private func verifyAPIKey() {
guard !apiKey.isEmpty else { return }
isVerifying = true
verificationStatus = .verifying
// Set the provider in AIService temporarily for verification
let originalProvider = aiService.selectedProvider
if model.provider == .groq {
aiService.selectedProvider = .groq
} else if model.provider == .elevenLabs {
aiService.selectedProvider = .elevenLabs
} else if model.provider == .deepgram {
aiService.selectedProvider = .deepgram
}
aiService.verifyAPIKey(apiKey) { [self] isValid in
DispatchQueue.main.async {
self.isVerifying = false
if isValid {
self.verificationStatus = .success
// Save the API key
UserDefaults.standard.set(self.apiKey, forKey: "\(self.providerKey)APIKey")
self.isConfiguredState = true
// Collapse the configuration section after successful verification
withAnimation(.easeInOut(duration: 0.3)) {
self.isExpanded = false
}
} else {
self.verificationStatus = .failure
}
// Restore original provider
aiService.selectedProvider = originalProvider
}
}
}
private func clearAPIKey() {
UserDefaults.standard.removeObject(forKey: "\(providerKey)APIKey")
apiKey = ""
verificationStatus = .none
isConfiguredState = false
// If this model is currently the default, clear it
if isCurrent {
Task {
await MainActor.run {
whisperState.currentTranscriptionModel = nil
UserDefaults.standard.removeObject(forKey: "CurrentTranscriptionModel")
}
}
}
withAnimation(.easeInOut(duration: 0.3)) {
isExpanded = false
}
}
}
// MARK: - Helper Views and Functions
private func progressDotsWithNumber(value: Double) -> some View {
HStack(spacing: 4) {
progressDots(value: value)
Text(String(format: "%.1f", value))
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(Color(.secondaryLabelColor))
}
}
private func progressDots(value: Double) -> some View {
HStack(spacing: 2) {
ForEach(0..<5) { index in
Circle()
.fill(index < Int(value / 2) ? performanceColor(value: value / 10) : Color(.quaternaryLabelColor))
.frame(width: 6, height: 6)
}
}
}
private func performanceColor(value: Double) -> Color {
switch value {
case 0.8...1.0: return Color(.systemGreen)
case 0.6..<0.8: return Color(.systemYellow)
case 0.4..<0.6: return Color(.systemOrange)
default: return Color(.systemRed)
}
}
// MARK: - Custom Model Card View
struct CustomModelCardView: View {
let model: CustomCloudModel
let isCurrent: Bool
var setDefaultAction: () -> Void
var deleteAction: () -> Void
var editAction: (CustomCloudModel) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Main card content
HStack(alignment: .top, spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
}
.frame(maxWidth: .infinity, alignment: .leading)
actionSection
}
.padding(16)
}
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else {
Text("Custom")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.orange.opacity(0.2)))
.foregroundColor(Color.orange)
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Provider
Label("Custom Provider", systemImage: "cloud")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// OpenAI Compatible
Label("OpenAI Compatible", systemImage: "checkmark.seal")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
}
Menu {
Button {
editAction(model)
} label: {
Label("Edit Model", systemImage: "pencil")
}
Button(role: .destructive) {
deleteAction()
} label: {
Label("Delete Model", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 14))
}
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.frame(width: 20, height: 20)
}
}
}
// MARK: - Native Apple Model Card View
struct NativeAppleModelCardView: View {
let model: NativeAppleModel
let isCurrent: Bool
var setDefaultAction: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 16) {
// Main Content
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
}
.frame(maxWidth: .infinity, alignment: .leading)
// Action Controls
actionSection
}
.padding(16)
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else {
Text("Built-in")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.blue.opacity(0.2)))
.foregroundColor(Color.blue)
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Native Apple
Label("Native Apple", systemImage: "apple.logo")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// On-Device
Label("On-Device", systemImage: "checkmark.shield")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Requires macOS 26+
Label("macOS 26+", systemImage: "macbook")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
}
}

View File

@ -1,6 +1,14 @@
import SwiftUI
import SwiftData
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?
@ -10,6 +18,9 @@ struct ModelManagementView: View {
@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 = ""
@ -59,74 +70,132 @@ struct ModelManagementView: View {
private var availableModelsSection: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Available Models")
.font(.title3)
.fontWeight(.semibold)
Text("(\(whisperState.allAvailableModels.count))")
.foregroundColor(.secondary)
.font(.subheadline)
Spacer()
}
VStack(spacing: 12) {
ForEach(whisperState.allAvailableModels, id: \.id) { model in
ModelCardRowView(
model: model,
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
// 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
}
},
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
)
}) {
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())
}
}
// 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
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 {
PromptCustomizationView(whisperPrompt: whisperPrompt)
} else {
VStack(spacing: 12) {
ForEach(filteredModels, id: \.id) { model in
ModelCardRowView(
model: model,
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
)
}
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 }
case .cloud:
let cloudProviders: [ModelProvider] = [.groq, .elevenLabs, .deepgram]
return whisperState.allAvailableModels.filter { cloudProviders.contains($0.provider) }
case .custom:
return whisperState.allAvailableModels.filter { $0.provider == .custom }
}
}
}

View File

@ -0,0 +1,113 @@
import SwiftUI
import AppKit
// MARK: - Native Apple Model Card View
struct NativeAppleModelCardView: View {
let model: NativeAppleModel
let isCurrent: Bool
var setDefaultAction: () -> Void
var body: some View {
HStack(alignment: .top, spacing: 16) {
// Main Content
VStack(alignment: .leading, spacing: 6) {
headerSection
metadataSection
descriptionSection
}
.frame(maxWidth: .infinity, alignment: .leading)
// Action Controls
actionSection
}
.padding(16)
.background(CardBackground(isSelected: isCurrent, useAccentGradientWhenSelected: isCurrent))
}
private var headerSection: some View {
HStack(alignment: .firstTextBaseline) {
Text(model.displayName)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(Color(.labelColor))
statusBadge
Spacer()
}
}
private var statusBadge: some View {
Group {
if isCurrent {
Text("Default")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.accentColor))
.foregroundColor(.white)
} else {
Text("Built-in")
.font(.system(size: 11, weight: .medium))
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(Color.blue.opacity(0.2)))
.foregroundColor(Color.blue)
}
}
}
private var metadataSection: some View {
HStack(spacing: 12) {
// Native Apple
Label("Native Apple", systemImage: "apple.logo")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Language
Label(model.language, systemImage: "globe")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// On-Device
Label("On-Device", systemImage: "checkmark.shield")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
// Requires macOS 26+
Label("macOS 26+", systemImage: "macbook")
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(1)
}
.lineLimit(1)
}
private var descriptionSection: some View {
Text(model.description)
.font(.system(size: 11))
.foregroundColor(Color(.secondaryLabelColor))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 4)
}
private var actionSection: some View {
HStack(spacing: 8) {
if isCurrent {
Text("Default Model")
.font(.system(size: 12))
.foregroundColor(Color(.secondaryLabelColor))
} else {
Button(action: setDefaultAction) {
Text("Set as Default")
.font(.system(size: 12))
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
}
}