Skip to content

PR 12: Settings UI - Provider Preferences #13

Description

@gilmanb1

Overview

Add AI provider preference settings to the Settings view, including provider selection dropdown, quality thresholds, and greyed-out state handling for settings not supported by the current provider.

Dependencies

Files to Modify

File Action Description
Sources/SortAI/App/SettingsView.swift Modify Add AI provider section
Sources/SortAI/Core/Configuration/AppConfiguration.swift Modify Add AIProviderConfiguration

Implementation Details

1. AIProviderConfiguration

/// Configuration for AI provider settings
struct AIProviderConfiguration: Codable, Sendable {
    var preference: ProviderPreference = .automatic
    var escalationThreshold: Double = 0.5
    var autoAcceptThreshold: Double = 0.7
    var autoInstallOllama: Bool = true
    
    // Ollama-specific
    var ollamaServerURL: String = "http://127.0.0.1:11434"
    var ollamaModel: String = "deepseek-r1:8b"
    var ollamaAutoDownload: Bool = true
    
    // Cloud-specific
    var cloudProvider: CloudProvider = .openai
    var openAIApiKey: String = ""
    var openAIModel: String = "gpt-4o-mini"
    var anthropicApiKey: String = ""
    var anthropicModel: String = "claude-3-haiku"
    
    enum CloudProvider: String, Codable, CaseIterable {
        case openai = "openai"
        case anthropic = "anthropic"
        
        var displayName: String {
            switch self {
            case .openai: return "OpenAI"
            case .anthropic: return "Anthropic"
            }
        }
    }
}

2. Settings UI Section

import SwiftUI

struct AIProviderSettingsSection: View {
    @Binding var config: AIProviderConfiguration
    @State private var settingsAvailability: ProviderSettingsAvailability = .allEnabled
    @State private var availableProviders: [String] = []
    
    var body: some View {
        Section("AI Provider") {
            // Provider preference dropdown
            Picker("LLM Provider", selection: $config.preference) {
                ForEach(ProviderPreference.allCases, id: \.self) { pref in
                    VStack(alignment: .leading) {
                        Text(pref.displayName)
                        Text(pref.description)
                            .font(.caption)
                            .foregroundColor(.secondary)
                    }
                    .tag(pref)
                }
            }
            .pickerStyle(.menu)
            
            // Active provider indicator
            HStack {
                Text("Active Provider")
                Spacer()
                ProviderBadge(provider: activeProvider)
            }
            
            // Quality thresholds
            VStack(alignment: .leading, spacing: 8) {
                HStack {
                    Text("Auto-accept threshold")
                    Spacer()
                    Text(String(format: "%.0f%%", config.autoAcceptThreshold * 100))
                        .foregroundColor(.secondary)
                }
                Slider(value: $config.autoAcceptThreshold, in: 0.5...1.0, step: 0.05)
                
                Text("Files with confidence above this will be auto-accepted")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
            
            VStack(alignment: .leading, spacing: 8) {
                HStack {
                    Text("Escalation threshold")
                    Spacer()
                    Text(String(format: "%.0f%%", config.escalationThreshold * 100))
                        .foregroundColor(.secondary)
                }
                Slider(value: $config.escalationThreshold, in: 0.1...0.9, step: 0.05)
                    .disabled(config.preference != .automatic)
                
                Text("Results below this will try the next provider (Automatic mode only)")
                    .font(.caption)
                    .foregroundColor(.secondary)
            }
        }
        
        // Ollama settings (greyed out when using Apple Intelligence only)
        Section {
            ollamaSettingsContent
        } header: {
            HStack {
                Text("Ollama Settings")
                if !isOllamaSettingsEnabled {
                    Spacer()
                    Image(systemName: "info.circle")
                        .foregroundColor(.secondary)
                        .help("Not available when using Apple Intelligence Only mode")
                }
            }
        }
        .disabled(!isOllamaSettingsEnabled)
        .opacity(isOllamaSettingsEnabled ? 1.0 : 0.5)
        
        // Cloud settings (greyed out unless cloud mode)
        Section {
            cloudSettingsContent
        } header: {
            HStack {
                Text("Cloud Settings")
                if !isCloudSettingsEnabled {
                    Spacer()
                    Image(systemName: "info.circle")
                        .foregroundColor(.secondary)
                        .help("Only available in Cloud mode")
                }
            }
        }
        .disabled(!isCloudSettingsEnabled)
        .opacity(isCloudSettingsEnabled ? 1.0 : 0.5)
    }
    
    // MARK: - Ollama Settings
    
    @ViewBuilder
    private var ollamaSettingsContent: some View {
        TextField("Server URL", text: $config.ollamaServerURL)
            .textFieldStyle(.roundedBorder)
        
        Picker("Model", selection: $config.ollamaModel) {
            Text("deepseek-r1:8b").tag("deepseek-r1:8b")
            Text("llama3.2").tag("llama3.2")
            Text("llama3.1").tag("llama3.1")
            Text("mistral").tag("mistral")
            Text("phi3").tag("phi3")
        }
        .disabled(!settingsAvailability.modelSelection)
        
        Toggle("Auto-download missing models", isOn: $config.ollamaAutoDownload)
        
        Toggle("Auto-install Ollama if not found", isOn: $config.autoInstallOllama)
    }
    
    // MARK: - Cloud Settings
    
    @ViewBuilder
    private var cloudSettingsContent: some View {
        Picker("Provider", selection: $config.cloudProvider) {
            ForEach(AIProviderConfiguration.CloudProvider.allCases, id: \.self) { provider in
                Text(provider.displayName).tag(provider)
            }
        }
        
        switch config.cloudProvider {
        case .openai:
            SecureField("API Key", text: $config.openAIApiKey)
                .textFieldStyle(.roundedBorder)
            
            Picker("Model", selection: $config.openAIModel) {
                Text("gpt-4o-mini").tag("gpt-4o-mini")
                Text("gpt-4o").tag("gpt-4o")
                Text("gpt-4-turbo").tag("gpt-4-turbo")
            }
            
        case .anthropic:
            SecureField("API Key", text: $config.anthropicApiKey)
                .textFieldStyle(.roundedBorder)
            
            Picker("Model", selection: $config.anthropicModel) {
                Text("claude-3-haiku").tag("claude-3-haiku")
                Text("claude-3-sonnet").tag("claude-3-sonnet")
                Text("claude-3-opus").tag("claude-3-opus")
            }
        }
    }
    
    // MARK: - Computed Properties
    
    private var isOllamaSettingsEnabled: Bool {
        config.preference != .appleIntelligenceOnly
    }
    
    private var isCloudSettingsEnabled: Bool {
        config.preference == .cloud
    }
    
    private var activeProvider: String {
        // This would come from UnifiedCategorizationService
        switch config.preference {
        case .automatic: return "apple-intelligence"
        case .appleIntelligenceOnly: return "apple-intelligence"
        case .preferOllama: return "ollama"
        case .cloud: return config.cloudProvider.rawValue
        }
    }
}

3. ProviderBadge Component

This is a small badge component showing which provider is active (can also be PR 13):

struct ProviderBadge: View {
    let provider: String
    
    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: iconName)
            Text(displayName)
        }
        .font(.caption)
        .padding(.horizontal, 8)
        .padding(.vertical, 4)
        .background(backgroundColor.opacity(0.2))
        .foregroundColor(backgroundColor)
        .cornerRadius(8)
    }
    
    private var displayName: String {
        switch provider {
        case "apple-intelligence": return "Apple Intelligence"
        case "ollama": return "Ollama"
        case "openai": return "OpenAI"
        case "anthropic": return "Anthropic"
        case "local-ml": return "Local ML"
        default: return provider.capitalized
        }
    }
    
    private var iconName: String {
        switch provider {
        case "apple-intelligence": return "apple.logo"
        case "ollama": return "server.rack"
        case "openai", "anthropic": return "cloud"
        case "local-ml": return "cpu"
        default: return "questionmark.circle"
        }
    }
    
    private var backgroundColor: Color {
        switch provider {
        case "apple-intelligence": return .blue
        case "ollama": return .green
        case "openai": return .teal
        case "anthropic": return .orange
        case "local-ml": return .purple
        default: return .gray
        }
    }
}

4. Integration with AppConfiguration

// In AppConfiguration.swift
extension AppConfiguration {
    var aiProvider: AIProviderConfiguration {
        get {
            // Load from UserDefaults or default
            guard let data = UserDefaults.standard.data(forKey: "aiProviderConfig"),
                  let config = try? JSONDecoder().decode(AIProviderConfiguration.self, from: data) else {
                return AIProviderConfiguration()
            }
            return config
        }
        set {
            if let data = try? JSONEncoder().encode(newValue) {
                UserDefaults.standard.set(data, forKey: "aiProviderConfig")
            }
        }
    }
}

UI Design

┌─────────────────────────────────────────────────────────────────┐
│  AI Provider                                                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  LLM Provider: [Automatic (Recommended)]  ▼                     │
│                                                                 │
│  Active Provider:    ┌──────────────────┐                       │
│                      │ 🍎 Apple Intel │                       │
│                      └──────────────────┘                       │
│                                                                 │
│  Auto-accept threshold:                          70%            │
│  ────────────────────────────●─────────────                     │
│  Files above this confidence are auto-accepted                  │
│                                                                 │
│  Escalation threshold:                           50%            │
│  ─────────────●────────────────────────────                     │
│  Results below this try the next provider                       │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│  Ollama Settings                               ⓘ (greyed out)  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Server URL: [http://127.0.0.1:11434]                           │
│  Model: [deepseek-r1:8b] ▼                                      │
│  ☑ Auto-download missing models                                 │
│  ☑ Auto-install Ollama if not found                             │
│                                                                 │
├─────────────────────────────────────────────────────────────────┤
│  Cloud Settings                                ⓘ (greyed out)  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Provider: [OpenAI] ▼                                           │
│  API Key: [••••••••••••••••]                                    │
│  Model: [gpt-4o-mini] ▼                                         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Acceptance Criteria

  • Provider preference dropdown shows all options
  • Active provider badge displays correctly
  • Quality threshold sliders work correctly
  • Ollama settings greyed out in Apple Intelligence Only mode
  • Cloud settings greyed out except in Cloud mode
  • Tooltip explains why settings are disabled
  • Settings persist to UserDefaults
  • API keys are stored securely

Testing

func testProviderPreferenceDropdown() {
    let view = AIProviderSettingsSection(config: .constant(AIProviderConfiguration()))
    
    // Verify all options present
    XCTAssertEqual(ProviderPreference.allCases.count, 4)
}

func testOllamaSettingsDisabledInAppleOnlyMode() {
    var config = AIProviderConfiguration()
    config.preference = .appleIntelligenceOnly
    
    let view = AIProviderSettingsSection(config: .constant(config))
    
    // Ollama settings should be disabled
    // (UI testing would verify this)
}

func testConfigurationPersistence() {
    var config = AIProviderConfiguration()
    config.preference = .preferOllama
    config.escalationThreshold = 0.6
    
    // Save
    let appConfig = AppConfiguration.shared
    appConfig.aiProvider = config
    
    // Reload
    let loaded = appConfig.aiProvider
    XCTAssertEqual(loaded.preference, .preferOllama)
    XCTAssertEqual(loaded.escalationThreshold, 0.6)
}

Estimated Size

~150 lines of code

Risk Assessment

Low - Standard SwiftUI settings view. Well-established patterns.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestphase-3Phase 3 - IntegrationuiUser interface changes

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions