From fe19b607477a1622dc07c777fc32937129ba69e6 Mon Sep 17 00:00:00 2001 From: Beingpax Date: Tue, 8 Jul 2025 07:59:38 +0545 Subject: [PATCH] Redesign AI model management with modern filter UI --- .../AI Models/CloudModelCardRowView.swift | 322 ++++++++ .../AI Models/CustomModelCardRowView.swift | 130 +++ .../AI Models/LanguageSelectionView.swift | 3 - .../AI Models/LocalModelCardRowView.swift | 218 +++++ .../Views/AI Models/ModelCardRowView.swift | 777 +----------------- .../Views/AI Models/ModelManagementView.swift | 193 +++-- .../AI Models/NativeModelCardRowView.swift | 113 +++ 7 files changed, 915 insertions(+), 841 deletions(-) create mode 100644 VoiceInk/Views/AI Models/CloudModelCardRowView.swift create mode 100644 VoiceInk/Views/AI Models/CustomModelCardRowView.swift create mode 100644 VoiceInk/Views/AI Models/LocalModelCardRowView.swift create mode 100644 VoiceInk/Views/AI Models/NativeModelCardRowView.swift diff --git a/VoiceInk/Views/AI Models/CloudModelCardRowView.swift b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift new file mode 100644 index 0000000..fd32638 --- /dev/null +++ b/VoiceInk/Views/AI Models/CloudModelCardRowView.swift @@ -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 + } + } +} diff --git a/VoiceInk/Views/AI Models/CustomModelCardRowView.swift b/VoiceInk/Views/AI Models/CustomModelCardRowView.swift new file mode 100644 index 0000000..58216bc --- /dev/null +++ b/VoiceInk/Views/AI Models/CustomModelCardRowView.swift @@ -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) + } + } +} diff --git a/VoiceInk/Views/AI Models/LanguageSelectionView.swift b/VoiceInk/Views/AI Models/LanguageSelectionView.swift index 8726767..8811ef2 100644 --- a/VoiceInk/Views/AI Models/LanguageSelectionView.swift +++ b/VoiceInk/Views/AI Models/LanguageSelectionView.swift @@ -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) } } diff --git a/VoiceInk/Views/AI Models/LocalModelCardRowView.swift b/VoiceInk/Views/AI Models/LocalModelCardRowView.swift new file mode 100644 index 0000000..7059f49 --- /dev/null +++ b/VoiceInk/Views/AI Models/LocalModelCardRowView.swift @@ -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) + } +} diff --git a/VoiceInk/Views/AI Models/ModelCardRowView.swift b/VoiceInk/Views/AI Models/ModelCardRowView.swift index 67a2b11..ddb3fba 100644 --- a/VoiceInk/Views/AI Models/ModelCardRowView.swift +++ b/VoiceInk/Views/AI Models/ModelCardRowView.swift @@ -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) - } - } - } -} +} \ No newline at end of file diff --git a/VoiceInk/Views/AI Models/ModelManagementView.swift b/VoiceInk/Views/AI Models/ModelManagementView.swift index 5f12a5b..69e0098 100644 --- a/VoiceInk/Views/AI Models/ModelManagementView.swift +++ b/VoiceInk/Views/AI Models/ModelManagementView.swift @@ -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 } + } + } } diff --git a/VoiceInk/Views/AI Models/NativeModelCardRowView.swift b/VoiceInk/Views/AI Models/NativeModelCardRowView.swift new file mode 100644 index 0000000..24f6a90 --- /dev/null +++ b/VoiceInk/Views/AI Models/NativeModelCardRowView.swift @@ -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) + } + } + } +}