285 lines
11 KiB
Swift
285 lines
11 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
|
|
struct ModelManagementView: View {
|
|
@ObservedObject var whisperState: WhisperState
|
|
@State private var modelToDelete: WhisperModel?
|
|
@StateObject private var aiService = AIService()
|
|
@EnvironmentObject private var enhancementService: AIEnhancementService
|
|
@Environment(\.modelContext) private var modelContext
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
defaultModelSection
|
|
languageSelectionSection
|
|
availableModelsSection
|
|
}
|
|
.padding(40)
|
|
}
|
|
.frame(minWidth: 600, minHeight: 500)
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
.alert(item: $modelToDelete) { model in
|
|
Alert(
|
|
title: Text("Delete Model"),
|
|
message: Text("Are you sure you want to delete the model '\(model.name)'?"),
|
|
primaryButton: .destructive(Text("Delete")) {
|
|
Task {
|
|
await whisperState.deleteModel(model)
|
|
}
|
|
},
|
|
secondaryButton: .cancel()
|
|
)
|
|
}
|
|
}
|
|
|
|
private var defaultModelSection: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Default Model")
|
|
.font(.headline)
|
|
.foregroundColor(.secondary)
|
|
Text(whisperState.currentModel.flatMap { model in
|
|
PredefinedModels.models.first { $0.name == model.name }?.displayName
|
|
} ?? "No model selected")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(Color(.windowBackgroundColor).opacity(0.4))
|
|
.cornerRadius(10)
|
|
}
|
|
|
|
private var languageSelectionSection: some View {
|
|
LanguageSelectionView(whisperState: whisperState)
|
|
}
|
|
|
|
private var availableModelsSection: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack {
|
|
Text("Available Models")
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
|
|
Text("(\(whisperState.predefinedModels.count))")
|
|
.foregroundColor(.secondary)
|
|
.font(.subheadline)
|
|
|
|
Spacer()
|
|
}
|
|
|
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 300, maximum: 400), spacing: 16)], spacing: 16) {
|
|
ForEach(whisperState.predefinedModels) { model in
|
|
modelCard(for: model)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.windowBackgroundColor).opacity(0.4))
|
|
.cornerRadius(10)
|
|
}
|
|
|
|
private func modelCard(for model: PredefinedModel) -> some View {
|
|
let isDownloaded = whisperState.availableModels.contains { $0.name == model.name }
|
|
let isCurrent = whisperState.currentModel?.name == model.name
|
|
|
|
return VStack(alignment: .leading, spacing: 12) {
|
|
// Model name and details
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(model.displayName)
|
|
.font(.headline)
|
|
Text("\(model.size) • \(model.language)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
modelStatusBadge(isDownloaded: isDownloaded, isCurrent: isCurrent)
|
|
}
|
|
|
|
// Description
|
|
Text(model.description)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
|
|
// Performance indicators
|
|
HStack(spacing: 16) {
|
|
performanceIndicator(label: "Speed", value: model.speed)
|
|
performanceIndicator(label: "Accuracy", value: model.accuracy)
|
|
ramUsageLabel(gb: model.ramUsage)
|
|
}
|
|
|
|
// Action buttons
|
|
HStack {
|
|
modelActionButton(isDownloaded: isDownloaded, isCurrent: isCurrent, model: model)
|
|
|
|
if isDownloaded {
|
|
Menu {
|
|
Button(action: {
|
|
if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) {
|
|
modelToDelete = downloadedModel
|
|
}
|
|
}) {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
|
|
Button(action: {
|
|
if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) {
|
|
NSWorkspace.shared.selectFile(downloadedModel.url.path, inFileViewerRootedAtPath: "")
|
|
}
|
|
}) {
|
|
Label("Show in Finder", systemImage: "folder")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.menuStyle(BorderlessButtonMenuStyle())
|
|
.frame(width: 30, height: 30)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
.background(Color(.windowBackgroundColor).opacity(0.9))
|
|
.cornerRadius(10)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.stroke(isCurrent ? Color.accentColor : Color.gray.opacity(0.2), lineWidth: isCurrent ? 2 : 1)
|
|
)
|
|
}
|
|
|
|
private func modelStatusBadge(isDownloaded: Bool, isCurrent: Bool) -> some View {
|
|
Group {
|
|
if isCurrent {
|
|
Text("Default")
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.green)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(8)
|
|
} else if isDownloaded {
|
|
Text("Downloaded")
|
|
.font(.caption)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(Color.indigo)
|
|
.foregroundColor(.white)
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performanceIndicator(label: String, value: Double) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(label)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
HStack(spacing: 2) {
|
|
ForEach(0..<5) { index in
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(index < Int(value * 5) ? performanceColor(value: value) : Color.secondary.opacity(0.2))
|
|
.frame(width: 16, height: 8)
|
|
}
|
|
}
|
|
|
|
Text(String(format: "%.1f", value * 10))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
private func performanceColor(value: Double) -> Color {
|
|
switch value {
|
|
case 0.8...: return .green
|
|
case 0.6..<0.8: return .yellow
|
|
case 0.4..<0.6: return .orange
|
|
default: return .red
|
|
}
|
|
}
|
|
|
|
private func modelActionButton(isDownloaded: Bool, isCurrent: Bool, model: PredefinedModel) -> some View {
|
|
Group {
|
|
if isCurrent {
|
|
Text("Default Model")
|
|
.foregroundColor(.white)
|
|
} else if isDownloaded {
|
|
Button("Set as Default") {
|
|
if let downloadedModel = whisperState.availableModels.first(where: { $0.name == model.name }) {
|
|
Task {
|
|
await whisperState.setDefaultModel(downloadedModel)
|
|
}
|
|
}
|
|
}
|
|
.foregroundColor(.white)
|
|
} else if whisperState.downloadProgress[model.name] != nil {
|
|
VStack {
|
|
ProgressView(value: whisperState.downloadProgress[model.name] ?? 0)
|
|
.progressViewStyle(LinearProgressViewStyle())
|
|
.animation(.linear, value: whisperState.downloadProgress[model.name])
|
|
Text("\(Int((whisperState.downloadProgress[model.name] ?? 0) * 100))%")
|
|
.font(.caption)
|
|
.animation(.none)
|
|
}
|
|
} else {
|
|
Button("Download Model") {
|
|
Task {
|
|
await whisperState.downloadModel(model)
|
|
}
|
|
}
|
|
.foregroundColor(.white)
|
|
}
|
|
}
|
|
.buttonStyle(GradientButtonStyle(isDownloaded: isDownloaded, isCurrent: isCurrent))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
private func ramUsageLabel(gb: Double) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("RAM")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text(formatRAMSize(gb))
|
|
.font(.system(size: 12, weight: .bold))
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
|
|
private func formatRAMSize(_ gb: Double) -> String {
|
|
if gb >= 1.0 {
|
|
return String(format: "%.1f GB", gb)
|
|
} else {
|
|
return String(format: "%d MB", Int(gb * 1024))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct GradientButtonStyle: ButtonStyle {
|
|
let isDownloaded: Bool
|
|
let isCurrent: Bool
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.padding(.vertical, 5)
|
|
.padding(.horizontal, 10)
|
|
.background(
|
|
Group {
|
|
if isCurrent {
|
|
LinearGradient(gradient: Gradient(colors: [Color.green, Color.green.opacity(0.7)]), startPoint: .top, endPoint: .bottom)
|
|
} else if isDownloaded {
|
|
LinearGradient(gradient: Gradient(colors: [Color.purple, Color.purple.opacity(0.7)]), startPoint: .top, endPoint: .bottom)
|
|
} else {
|
|
LinearGradient(gradient: Gradient(colors: [Color.blue, Color.blue.opacity(0.7)]), startPoint: .top, endPoint: .bottom)
|
|
}
|
|
}
|
|
)
|
|
.cornerRadius(10)
|
|
.shadow(color: Color.black.opacity(0.2), radius: 5, x: 0, y: 2)
|
|
.scaleEffect(configuration.isPressed ? 0.95 : 1)
|
|
.animation(.easeInOut(duration: 0.2), value: configuration.isPressed)
|
|
}
|
|
}
|