Redesign AI model management with modern filter UI
This commit is contained in:
parent
a519934232
commit
fe19b60747
322
VoiceInk/Views/AI Models/CloudModelCardRowView.swift
Normal file
322
VoiceInk/Views/AI Models/CloudModelCardRowView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
130
VoiceInk/Views/AI Models/CustomModelCardRowView.swift
Normal file
130
VoiceInk/Views/AI Models/CustomModelCardRowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
218
VoiceInk/Views/AI Models/LocalModelCardRowView.swift
Normal file
218
VoiceInk/Views/AI Models/LocalModelCardRowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
VoiceInk/Views/AI Models/NativeModelCardRowView.swift
Normal file
113
VoiceInk/Views/AI Models/NativeModelCardRowView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user