SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
17.2 KB · 458 lines swift
Raw Blame History
1//2//  SettingsView.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Settings window (720×520, toolbar-style tabs): Providers & Keys, Models,9//  Appearance, Shortcuts, Advanced.10//1112import SwiftUI1314struct SettingsView: View {15    var body: some View {16        TabView {17            ProvidersSettingsTab()18                .tabItem { Label("Providers & Keys", systemImage: "key") }19            ModelsSettingsTab()20                .tabItem { Label("Models", systemImage: "cpu") }21            AppearanceSettingsTab()22                .tabItem { Label("Appearance", systemImage: "paintbrush") }23            ShortcutsSettingsTab()24                .tabItem { Label("Shortcuts", systemImage: "keyboard") }25            AdvancedSettingsTab()26                .tabItem { Label("Advanced", systemImage: "gearshape.2") }27        }28        .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight)29    }30}3132// MARK: - Providers & Keys3334struct ProvidersSettingsTab: View {35    @EnvironmentObject private var vault: KeyVaultStore36    @EnvironmentObject private var catalog: ModelCatalog37    @State private var draftKeys: [ProviderID: String] = [:]3839    var body: some View {40        ScrollView {41            VStack(spacing: ZyquoSpacing.xs) {42                ForEach(ProviderID.builtIn) { provider in43                    providerRow(provider)44                    if provider != ProviderID.builtIn.last { ZyquoHairline() }45                }46            }47            .padding(ZyquoMetrics.contentInset)48        }49        .background(ZyquoColor.background)50    }5152    private func providerRow(_ provider: ProviderID) -> some View {53        HStack(spacing: ZyquoSpacing.sm) {54            Image(systemName: provider.symbolName)55                .font(.system(size: 14))56                .foregroundStyle(ZyquoColor.accent)57                .frame(width: 22)58            VStack(alignment: .leading, spacing: 1) {59                HStack(spacing: ZyquoSpacing.xxs) {60                    Text(provider.displayName)61                        .font(ZyquoFont.bodyEmphasis(size: 13))62                        .foregroundStyle(ZyquoColor.textPrimary)63                    statusIndicator(provider)64                }65                statusDetail(provider)66            }67            Spacer()68            keyField(provider)69            testButton(provider)70            if vault.hasKey(for: provider) {71                Button {72                    vault.deleteKey(for: provider)73                } label: {74                    Image(systemName: "trash")75                        .font(.system(size: 11))76                        .foregroundStyle(ZyquoColor.danger)77                }78                .buttonStyle(.plain)79                .help("Delete key")80            }81        }82        .padding(.vertical, ZyquoSpacing.xxs)83    }8485    @ViewBuilder86    private func statusIndicator(_ provider: ProviderID) -> some View {87        switch vault.statuses[provider] ?? .unset {88        case .unset: StatusDot(status: .unset)89        case .saved: StatusDot(status: .unset).overlay(Circle().strokeBorder(ZyquoColor.textSecondary, lineWidth: 1))90        case .testing: ProgressView().controlSize(.mini)91        case .verified: StatusDot(status: .verified)92        case .failed: StatusDot(status: .failed)93        }94    }9596    @ViewBuilder97    private func statusDetail(_ provider: ProviderID) -> some View {98        switch vault.statuses[provider] ?? .unset {99        case .verified(let latency):100            Text(String(format: "Verified · %.0f ms", latency * 1000))101                .font(ZyquoFont.caption)102                .foregroundStyle(ZyquoColor.success)103        case .failed(let message):104            Text(message)105                .font(ZyquoFont.caption)106                .foregroundStyle(ZyquoColor.danger)107                .lineLimit(1)108                .help(message)109        case .saved:110            Text(vault.redactedKeys[provider] ?? "")111                .font(ZyquoFont.caption)112                .foregroundStyle(ZyquoColor.textTertiary)113        default:114            Text("No key")115                .font(ZyquoFont.caption)116                .foregroundStyle(ZyquoColor.textTertiary)117        }118    }119120    private func keyField(_ provider: ProviderID) -> some View {121        SecureField(122            vault.hasKey(for: provider) ? (vault.redactedKeys[provider] ?? "") : "API key",123            text: Binding(124                get: { draftKeys[provider] ?? "" },125                set: { draftKeys[provider] = $0 }126            )127        )128        .textFieldStyle(.roundedBorder)129        .font(ZyquoFont.code(size: 11))130        .frame(width: 220)131        .onSubmit { saveDraft(provider) }132    }133134    private func testButton(_ provider: ProviderID) -> some View {135        Button("Test") {136            saveDraft(provider)137            Task { await vault.testKey(for: provider, catalog: catalog) }138        }139        .controlSize(.small)140        .disabled(!vault.hasKey(for: provider) && (draftKeys[provider] ?? "").isEmpty)141    }142143    private func saveDraft(_ provider: ProviderID) {144        if let draft = draftKeys[provider], !draft.trimmingCharacters(in: .whitespaces).isEmpty {145            vault.setKey(draft, for: provider)146            draftKeys[provider] = ""147        }148    }149}150151// MARK: - Models152153struct ModelsSettingsTab: View {154    @EnvironmentObject private var catalog: ModelCatalog155    @EnvironmentObject private var vault: KeyVaultStore156    @State private var selectedProvider: ProviderID = .openai157    @State private var refreshing = false158    @State private var refreshResult: String?159    @State private var showingCustomModelSheet = false160161    var body: some View {162        VStack(spacing: 0) {163            HStack {164                Picker("Provider", selection: $selectedProvider) {165                    ForEach(ProviderID.builtIn) { provider in166                        Text(provider.displayName).tag(provider)167                    }168                }169                .frame(width: 240)170                Spacer()171                if let result = refreshResult {172                    Text(result)173                        .font(ZyquoFont.caption)174                        .foregroundStyle(ZyquoColor.textSecondary)175                }176                Button {177                    refreshModels()178                } label: {179                    if refreshing {180                        ProgressView().controlSize(.small)181                    } else {182                        Label("Refresh from API", systemImage: "arrow.clockwise")183                    }184                }185                .controlSize(.small)186                .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider))187                Button {188                    showingCustomModelSheet = true189                } label: {190                    Label("Add Custom", systemImage: "plus")191                }192                .controlSize(.small)193            }194            .padding(ZyquoSpacing.sm)195            ZyquoHairline()196            modelTable197        }198        .background(ZyquoColor.background)199        .sheet(isPresented: $showingCustomModelSheet) {200            CustomModelSheet()201        }202    }203204    private var modelTable: some View {205        ScrollView {206            LazyVStack(spacing: 1) {207                ForEach(catalog.models(for: selectedProvider)) { model in208                    modelRow(model)209                }210                let unknown = catalog.unknownLiveIDs(for: selectedProvider)211                if !unknown.isEmpty {212                    Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))")213                        .font(ZyquoFont.caption)214                        .foregroundStyle(ZyquoColor.textTertiary)215                        .frame(maxWidth: .infinity, alignment: .leading)216                        .padding(ZyquoSpacing.sm)217                }218            }219            .padding(ZyquoSpacing.sm)220        }221    }222223    private func modelRow(_ model: AIModel) -> some View {224        HStack(spacing: ZyquoSpacing.xs) {225            Button {226                if catalog.favoriteIDs.contains(model.id) {227                    catalog.favoriteIDs.remove(model.id)228                } else {229                    catalog.favoriteIDs.insert(model.id)230                }231            } label: {232                Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star")233                    .font(.system(size: 10))234                    .foregroundStyle(catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary)235            }236            .buttonStyle(.plain)237            VStack(alignment: .leading, spacing: 0) {238                HStack(spacing: ZyquoSpacing.xxs) {239                    Text(model.displayName)240                        .font(ZyquoFont.body(size: 12.5))241                        .foregroundStyle(ZyquoColor.textPrimary)242                    if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.accent) }243                    if model.isLegacy { ZyquoBadge(text: "Legacy") }244                }245                Text(model.id)246                    .font(ZyquoFont.code(size: 10))247                    .foregroundStyle(ZyquoColor.textTertiary)248            }249            Spacer()250            HStack(spacing: ZyquoSpacing.xxs) {251                if model.capabilities.vision { ZyquoBadge(text: "vision") }252                if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") }253                if model.capabilities.tools { ZyquoBadge(text: "tools") }254            }255            Text(model.contextBadge)256                .font(ZyquoFont.caption)257                .foregroundStyle(ZyquoColor.textSecondary)258                .frame(width: 64, alignment: .trailing)259            Text(pricingText(model))260                .font(ZyquoFont.caption)261                .foregroundStyle(ZyquoColor.textTertiary)262                .frame(width: 110, alignment: .trailing)263        }264        .padding(.vertical, 3)265        .padding(.horizontal, ZyquoSpacing.xs)266        .zyquoHoverHighlight()267    }268269    private func pricingText(_ model: AIModel) -> String {270        guard let pricing = model.pricing else { return "—" }271        return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok)272    }273274    private func refreshModels() {275        refreshing = true276        refreshResult = nil277        let provider = selectedProvider278        Task {279            defer { refreshing = false }280            do {281                let key = try vault.apiKey(for: provider)282                let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key)283                catalog.applyLiveListing(ids, for: provider)284                let unknown = catalog.unknownLiveIDs(for: provider).count285                refreshResult = "\(ids.count) live models · \(unknown) not in catalog"286            } catch {287                refreshResult = error.localizedDescription288            }289        }290    }291}292293/// Custom model editor: any OpenAI-compatible endpoint (OpenRouter, Groq…).294struct CustomModelSheet: View {295    @EnvironmentObject private var catalog: ModelCatalog296    @Environment(\.dismiss) private var dismiss297    @State private var modelID = ""298    @State private var displayName = ""299    @State private var baseURL = ""300    @State private var contextWindow = 128_000301    @State private var supportsVision = false302303    var body: some View {304        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {305            Text("Custom Model")306                .font(ZyquoFont.title)307            Text("Any OpenAI-compatible chat endpoint (OpenRouter, Groq, local gateways…). The custom key is stored under the Custom provider slot in the encrypted vault.")308                .font(ZyquoFont.caption)309                .foregroundStyle(ZyquoColor.textSecondary)310            Form {311                TextField("Model ID (as sent to the API)", text: $modelID)312                TextField("Display name", text: $displayName)313                TextField("Base URL (e.g. https://openrouter.ai/api/v1)", text: $baseURL)314                TextField("Context window", value: $contextWindow, format: .number)315                Toggle("Supports vision", isOn: $supportsVision)316            }317            HStack {318                Spacer()319                Button("Cancel") { dismiss() }320                Button("Add") { add() }321                    .buttonStyle(.borderedProminent)322                    .disabled(modelID.isEmpty || URL(string: baseURL) == nil)323            }324        }325        .padding(ZyquoSpacing.xl)326        .frame(width: 440)327    }328329    private func add() {330        let model = AIModel(331            id: modelID,332            provider: .custom,333            displayName: displayName.isEmpty ? modelID : displayName,334            contextWindow: contextWindow,335            maxOutputTokens: nil,336            capabilities: ModelCapabilities(vision: supportsVision, tools: false, jsonMode: false),337            pricing: nil,338            parameterSupport: .openAIDefault,339            customBaseURL: URL(string: baseURL)340        )341        catalog.customModels.append(model)342        dismiss()343    }344}345346// MARK: - Appearance347348struct AppearanceSettingsTab: View {349    @EnvironmentObject private var appearance: AppearanceStore350351    var body: some View {352        Form {353            Picker("Theme", selection: $appearance.themeMode) {354                ForEach(ThemeMode.allCases) { mode in355                    Text(mode.displayName).tag(mode)356                }357            }358            .pickerStyle(.segmented)359360            Picker("Accent", selection: $appearance.accent) {361                ForEach(AccentChoice.allCases) { choice in362                    Text(choice.displayName).tag(choice)363                }364            }365366            VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {367                HStack {368                    Text("Chat font size")369                    Spacer()370                    Text(String(format: "%.1f pt", appearance.chatFontSize))371                        .font(ZyquoFont.caption)372                        .foregroundStyle(ZyquoColor.textSecondary)373                        .monospacedDigit()374                }375                Slider(value: $appearance.chatFontSize, in: 12...18, step: 0.5)376                // Live preview377                VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {378                    Text("Preview")379                        .font(ZyquoFont.caption)380                        .foregroundStyle(ZyquoColor.textTertiary)381                    Text("The quick brown fox jumps over the lazy dog — Zyquo Cloud renders chat text at this size, with generous 1.45 line height for readability.")382                        .font(ZyquoFont.body(size: appearance.chatFontSize))383                        .lineSpacing(appearance.chatFontSize * ZyquoFont.bodyLineSpacingFactor)384                        .foregroundStyle(ZyquoColor.textPrimary)385                        .padding(ZyquoSpacing.sm)386                        .frame(maxWidth: .infinity, alignment: .leading)387                        .background(388                            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)389                                .fill(ZyquoColor.surface)390                                .overlay(391                                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)392                                        .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)393                                )394                        )395                }396            }397        }398        .formStyle(.grouped)399    }400}401402// MARK: - Shortcuts403404struct ShortcutsSettingsTab: View {405    private static let shortcuts: [(String, String)] = [406        ("New chat", "⌘N"),407        ("Model switcher / command palette", "⌘K"),408        ("Search conversations", "⌘F"),409        ("Send message", "⌘↩"),410        ("Export conversation", "⌘⇧E"),411        ("Quick Chat panel", "⌥Space"),412        ("Settings", "⌘,"),413    ]414415    var body: some View {416        Form {417            ForEach(Self.shortcuts, id: \.0) { name, keys in418                LabeledContent(name) {419                    Text(keys)420                        .font(ZyquoFont.code(size: 12))421                        .foregroundStyle(ZyquoColor.textSecondary)422                }423            }424        }425        .formStyle(.grouped)426    }427}428429// MARK: - Advanced430431struct AdvancedSettingsTab: View {432    @EnvironmentObject private var store: ConversationStore433434    var body: some View {435        Form {436            Section("Default system prompt") {437                TextEditor(text: $store.defaultSystemPrompt)438                    .font(ZyquoFont.body(size: 12.5))439                    .frame(height: 90)440            }441            Section("Data") {442                LabeledContent("Data folder") {443                    Button("Reveal in Finder") {444                        NSWorkspace.shared.activateFileViewerSelecting([445                            PersistenceService.shared.rootDirectory446                        ])447                    }448                    .controlSize(.small)449                }450                Text("Conversations, settings, and the encrypted key vault live in ~/Library/Application Support/ZyquoCloud/. The vault (vault.zq) is bound to this Mac and can't be decrypted elsewhere.")451                    .font(ZyquoFont.caption)452                    .foregroundStyle(ZyquoColor.textTertiary)453            }454        }455        .formStyle(.grouped)456    }457}458