570 lines
20 KiB
Swift
570 lines
20 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
struct ModelCardRowView: View {
|
|
let model: any TranscriptionModel
|
|
let isDownloaded: Bool
|
|
let isCurrent: Bool
|
|
let downloadProgress: [String: Double]
|
|
let modelURL: URL?
|
|
|
|
// Actions
|
|
var deleteAction: () -> Void
|
|
var setDefaultAction: () -> Void
|
|
var downloadAction: () -> Void
|
|
|
|
var body: some View {
|
|
Group {
|
|
switch model.provider {
|
|
case .local:
|
|
if let localModel = model as? LocalModel {
|
|
LocalModelCardView(
|
|
model: localModel,
|
|
isDownloaded: isDownloaded,
|
|
isCurrent: isCurrent,
|
|
downloadProgress: downloadProgress,
|
|
modelURL: modelURL,
|
|
deleteAction: deleteAction,
|
|
setDefaultAction: setDefaultAction,
|
|
downloadAction: downloadAction
|
|
)
|
|
}
|
|
case .groq, .elevenLabs:
|
|
if let cloudModel = model as? CloudModel {
|
|
CloudModelCardView(
|
|
model: cloudModel,
|
|
isCurrent: isCurrent,
|
|
setDefaultAction: setDefaultAction
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
@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"
|
|
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)
|
|
|
|
// 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 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
|
|
}
|
|
|
|
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
|
|
|
|
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)
|
|
}
|
|
}
|