vOOice/VoiceInk/Views/AI Models/AddCustomModelView.swift
Beingpax 948033ac28 Migrate API key storage to Keychain with iCloud sync
Move API keys from UserDefaults to secure Keychain storage. Add KeychainService and APIKeyManager for centralized key management. Enable iCloud Keychain sync for cross-device sharing between macOS and iOS.
2026-01-05 22:28:34 +05:45

286 lines
12 KiB
Swift

import SwiftUI
struct AddCustomModelCardView: View {
@ObservedObject var customModelManager: CustomModelManager
var onModelAdded: () -> Void
var editingModel: CustomCloudModel? = nil
@State private var isExpanded = false
@State private var displayName = ""
@State private var apiEndpoint = ""
@State private var apiKey = ""
@State private var modelName = ""
@State private var isMultilingual = true
@State private var validationErrors: [String] = []
@State private var showingAlert = false
@State private var isSaving = false
var body: some View {
VStack(spacing: 0) {
// Simple Add Model Button
if !isExpanded {
Button(action: {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = true
// Pre-fill values - either from editing model or defaults
if let editing = editingModel {
displayName = editing.displayName
apiEndpoint = editing.apiEndpoint
apiKey = editing.apiKey
modelName = editing.modelName
isMultilingual = editing.isMultilingualModel
} else {
// Pre-fill some default values when adding new
if apiEndpoint.isEmpty {
apiEndpoint = "https://api.example.com/v1/audio/transcriptions"
}
if modelName.isEmpty {
modelName = "large-v3-turbo"
}
}
}
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
.font(.system(size: 14, weight: .medium))
Text(editingModel != nil ? "Edit Model" : "Add Model")
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.accentColor)
.cornerRadius(12)
}
.buttonStyle(.plain)
.shadow(color: Color.accentColor.opacity(0.3), radius: 8, y: 4)
}
// Expandable Form Section
if isExpanded {
VStack(alignment: .leading, spacing: 20) {
// Header
HStack {
Text(editingModel != nil ? "Edit Custom Model" : "Add Custom Model")
.font(.headline)
.foregroundColor(.primary)
Spacer()
Button(action: {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = false
clearForm()
}
}) {
Image(systemName: "xmark")
.font(.system(size: 12, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
// Disclaimer
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
.font(.caption)
Text("Only OpenAI-compatible transcription APIs are supported")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.1))
.cornerRadius(8)
// Form fields
VStack(alignment: .leading, spacing: 16) {
FormField(title: "Display Name", text: $displayName, placeholder: "My Custom Model")
FormField(title: "API Endpoint", text: $apiEndpoint, placeholder: "https://api.example.com/v1/audio/transcriptions")
FormField(title: "API Key", text: $apiKey, placeholder: "your-api-key", isSecure: true)
FormField(title: "Model Name", text: $modelName, placeholder: "whisper-1")
Toggle("Multilingual Model", isOn: $isMultilingual)
}
// Action buttons
HStack(spacing: 12) {
Button(action: {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = false
clearForm()
}
}) {
Text("Cancel")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1))
.cornerRadius(8)
}
.buttonStyle(.plain)
Button(action: {
addModel()
}) {
HStack(spacing: 6) {
if isSaving {
ProgressView()
.scaleEffect(0.8)
.frame(width: 14, height: 14)
} else {
Image(systemName: editingModel != nil ? "checkmark.circle.fill" : "plus.circle.fill")
.font(.system(size: 14))
}
Text(editingModel != nil ? "Update Model" : "Add Model")
.font(.system(size: 13, weight: .medium))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isFormValid ? Color(.controlAccentColor) : Color.secondary)
.shadow(color: (isFormValid ? Color(.controlAccentColor) : Color.secondary).opacity(0.2), radius: 2, x: 0, y: 1)
)
}
.buttonStyle(.plain)
.disabled(!isFormValid || isSaving)
}
}
.padding(16)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color(.windowBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color(.separatorColor), lineWidth: 1)
)
)
}
}
.alert("Validation Errors", isPresented: $showingAlert) {
Button("OK") { }
} message: {
Text(validationErrors.joined(separator: "\n"))
}
.onChange(of: editingModel) { oldValue, newValue in
if newValue != nil {
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = true
// Pre-fill values from editing model
if let editing = newValue {
displayName = editing.displayName
apiEndpoint = editing.apiEndpoint
apiKey = editing.apiKey
modelName = editing.modelName
isMultilingual = editing.isMultilingualModel
}
}
}
}
}
private var isFormValid: Bool {
!displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!apiEndpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!modelName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func clearForm() {
displayName = ""
apiEndpoint = ""
apiKey = ""
modelName = ""
isMultilingual = true
}
private func addModel() {
let trimmedDisplayName = displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedApiEndpoint = apiEndpoint.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedApiKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedModelName = modelName.trimmingCharacters(in: .whitespacesAndNewlines)
// Generate a name from display name (lowercase, no spaces)
let generatedName = trimmedDisplayName.lowercased().replacingOccurrences(of: " ", with: "-")
validationErrors = customModelManager.validateModel(
name: generatedName,
displayName: trimmedDisplayName,
apiEndpoint: trimmedApiEndpoint,
apiKey: trimmedApiKey,
modelName: trimmedModelName,
excludingId: editingModel?.id
)
if !validationErrors.isEmpty {
showingAlert = true
return
}
isSaving = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if let editing = editingModel {
let updatedModel = CustomCloudModel(
id: editing.id,
name: generatedName,
displayName: trimmedDisplayName,
description: "Custom transcription model",
apiEndpoint: trimmedApiEndpoint,
modelName: trimmedModelName,
isMultilingual: isMultilingual
)
APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: editing.id)
customModelManager.updateCustomModel(updatedModel)
} else {
let customModel = CustomCloudModel(
name: generatedName,
displayName: trimmedDisplayName,
description: "Custom transcription model",
apiEndpoint: trimmedApiEndpoint,
modelName: trimmedModelName,
isMultilingual: isMultilingual
)
APIKeyManager.shared.saveCustomModelAPIKey(trimmedApiKey, forModelId: customModel.id)
customModelManager.addCustomModel(customModel)
}
onModelAdded()
withAnimation(.interpolatingSpring(stiffness: 170, damping: 20)) {
isExpanded = false
clearForm()
isSaving = false
}
}
}
}
struct FormField: View {
let title: String
@Binding var text: String
let placeholder: String
var isSecure: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.subheadline)
.fontWeight(.medium)
.foregroundColor(.primary)
if isSecure {
SecureField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
} else {
TextField(placeholder, text: $text)
.textFieldStyle(.roundedBorder)
}
}
}
}