// // SettingsView.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Settings window (720×520, toolbar-style tabs): Providers & Keys, Models, // Appearance, Shortcuts, Advanced. // import SwiftUI struct SettingsView: View { var body: some View { TabView { ProvidersSettingsTab() .tabItem { Label("Providers & Keys", systemImage: "key") } ModelsSettingsTab() .tabItem { Label("Models", systemImage: "cpu") } AppearanceSettingsTab() .tabItem { Label("Appearance", systemImage: "paintbrush") } ShortcutsSettingsTab() .tabItem { Label("Shortcuts", systemImage: "keyboard") } AdvancedSettingsTab() .tabItem { Label("Advanced", systemImage: "gearshape.2") } } .frame(width: ZyquoMetrics.settingsWidth, height: ZyquoMetrics.settingsHeight) } } // MARK: - Providers & Keys struct ProvidersSettingsTab: View { @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var catalog: ModelCatalog @State private var draftKeys: [ProviderID: String] = [:] var body: some View { ScrollView { VStack(spacing: ZyquoSpacing.xs) { ForEach(ProviderID.builtIn) { provider in providerRow(provider) if provider != ProviderID.builtIn.last { ZyquoHairline() } } } .padding(ZyquoMetrics.contentInset) } .background(ZyquoColor.background) } private func providerRow(_ provider: ProviderID) -> some View { HStack(spacing: ZyquoSpacing.sm) { Image(systemName: provider.symbolName) .font(.system(size: 14)) .foregroundStyle(ZyquoColor.accent) .frame(width: 22) VStack(alignment: .leading, spacing: 1) { HStack(spacing: ZyquoSpacing.xxs) { Text(provider.displayName) .font(ZyquoFont.bodyEmphasis(size: 13)) .foregroundStyle(ZyquoColor.textPrimary) statusIndicator(provider) } statusDetail(provider) } Spacer() keyField(provider) testButton(provider) if vault.hasKey(for: provider) { Button { vault.deleteKey(for: provider) } label: { Image(systemName: "trash") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.danger) } .buttonStyle(.plain) .help("Delete key") } } .padding(.vertical, ZyquoSpacing.xxs) } @ViewBuilder private func statusIndicator(_ provider: ProviderID) -> some View { switch vault.statuses[provider] ?? .unset { case .unset: StatusDot(status: .unset) case .saved: StatusDot(status: .unset).overlay(Circle().strokeBorder(ZyquoColor.textSecondary, lineWidth: 1)) case .testing: ProgressView().controlSize(.mini) case .verified: StatusDot(status: .verified) case .failed: StatusDot(status: .failed) } } @ViewBuilder private func statusDetail(_ provider: ProviderID) -> some View { switch vault.statuses[provider] ?? .unset { case .verified(let latency): Text(String(format: "Verified · %.0f ms", latency * 1000)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.success) case .failed(let message): Text(message) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.danger) .lineLimit(1) .help(message) case .saved: Text(vault.redactedKeys[provider] ?? "") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) default: Text("No key") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } private func keyField(_ provider: ProviderID) -> some View { SecureField( vault.hasKey(for: provider) ? (vault.redactedKeys[provider] ?? "") : "API key", text: Binding( get: { draftKeys[provider] ?? "" }, set: { draftKeys[provider] = $0 } ) ) .textFieldStyle(.roundedBorder) .font(ZyquoFont.code(size: 11)) .frame(width: 220) .onSubmit { saveDraft(provider) } } private func testButton(_ provider: ProviderID) -> some View { Button("Test") { saveDraft(provider) Task { await vault.testKey(for: provider, catalog: catalog) } } .controlSize(.small) .disabled(!vault.hasKey(for: provider) && (draftKeys[provider] ?? "").isEmpty) } private func saveDraft(_ provider: ProviderID) { if let draft = draftKeys[provider], !draft.trimmingCharacters(in: .whitespaces).isEmpty { vault.setKey(draft, for: provider) draftKeys[provider] = "" } } } // MARK: - Models struct ModelsSettingsTab: View { @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @State private var selectedProvider: ProviderID = .openai @State private var refreshing = false @State private var refreshResult: String? @State private var showingCustomModelSheet = false var body: some View { VStack(spacing: 0) { HStack { Picker("Provider", selection: $selectedProvider) { ForEach(ProviderID.builtIn) { provider in Text(provider.displayName).tag(provider) } } .frame(width: 240) Spacer() if let result = refreshResult { Text(result) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } Button { refreshModels() } label: { if refreshing { ProgressView().controlSize(.small) } else { Label("Refresh from API", systemImage: "arrow.clockwise") } } .controlSize(.small) .disabled(refreshing || !selectedProvider.supportsModelListing || !vault.hasKey(for: selectedProvider)) Button { showingCustomModelSheet = true } label: { Label("Add Custom", systemImage: "plus") } .controlSize(.small) } .padding(ZyquoSpacing.sm) ZyquoHairline() modelTable } .background(ZyquoColor.background) .sheet(isPresented: $showingCustomModelSheet) { CustomModelSheet() } } private var modelTable: some View { ScrollView { LazyVStack(spacing: 1) { ForEach(catalog.models(for: selectedProvider)) { model in modelRow(model) } let unknown = catalog.unknownLiveIDs(for: selectedProvider) if !unknown.isEmpty { Text("Live on the API but not in the catalog: \(unknown.joined(separator: ", "))") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.sm) } } .padding(ZyquoSpacing.sm) } } private func modelRow(_ model: AIModel) -> some View { HStack(spacing: ZyquoSpacing.xs) { Button { if catalog.favoriteIDs.contains(model.id) { catalog.favoriteIDs.remove(model.id) } else { catalog.favoriteIDs.insert(model.id) } } label: { Image(systemName: catalog.favoriteIDs.contains(model.id) ? "star.fill" : "star") .font(.system(size: 10)) .foregroundStyle(catalog.favoriteIDs.contains(model.id) ? ZyquoColor.warning : ZyquoColor.textTertiary) } .buttonStyle(.plain) VStack(alignment: .leading, spacing: 0) { HStack(spacing: ZyquoSpacing.xxs) { Text(model.displayName) .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(ZyquoColor.textPrimary) if model.isRecommended { ZyquoBadge(text: "Featured", color: ZyquoColor.accent) } if model.isLegacy { ZyquoBadge(text: "Legacy") } } Text(model.id) .font(ZyquoFont.code(size: 10)) .foregroundStyle(ZyquoColor.textTertiary) } Spacer() HStack(spacing: ZyquoSpacing.xxs) { if model.capabilities.vision { ZyquoBadge(text: "vision") } if model.capabilities.reasoning { ZyquoBadge(text: "reasoning") } if model.capabilities.tools { ZyquoBadge(text: "tools") } } Text(model.contextBadge) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) .frame(width: 64, alignment: .trailing) Text(pricingText(model)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .frame(width: 110, alignment: .trailing) } .padding(.vertical, 3) .padding(.horizontal, ZyquoSpacing.xs) .zyquoHoverHighlight() } private func pricingText(_ model: AIModel) -> String { guard let pricing = model.pricing else { return "—" } return String(format: "$%.2f / $%.2f", pricing.inputPerMTok, pricing.outputPerMTok) } private func refreshModels() { refreshing = true refreshResult = nil let provider = selectedProvider Task { defer { refreshing = false } do { let key = try vault.apiKey(for: provider) let ids = try await ProviderRegistry.client(for: provider).listModelIDs(apiKey: key) catalog.applyLiveListing(ids, for: provider) let unknown = catalog.unknownLiveIDs(for: provider).count refreshResult = "\(ids.count) live models · \(unknown) not in catalog" } catch { refreshResult = error.localizedDescription } } } } /// Custom model editor: any OpenAI-compatible endpoint (OpenRouter, Groq…). struct CustomModelSheet: View { @EnvironmentObject private var catalog: ModelCatalog @Environment(\.dismiss) private var dismiss @State private var modelID = "" @State private var displayName = "" @State private var baseURL = "" @State private var contextWindow = 128_000 @State private var supportsVision = false var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { Text("Custom Model") .font(ZyquoFont.title) Text("Any OpenAI-compatible chat endpoint (OpenRouter, Groq, local gateways…). The custom key is stored under the Custom provider slot in the encrypted vault.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) Form { TextField("Model ID (as sent to the API)", text: $modelID) TextField("Display name", text: $displayName) TextField("Base URL (e.g. https://openrouter.ai/api/v1)", text: $baseURL) TextField("Context window", value: $contextWindow, format: .number) Toggle("Supports vision", isOn: $supportsVision) } HStack { Spacer() Button("Cancel") { dismiss() } Button("Add") { add() } .buttonStyle(.borderedProminent) .disabled(modelID.isEmpty || URL(string: baseURL) == nil) } } .padding(ZyquoSpacing.xl) .frame(width: 440) } private func add() { let model = AIModel( id: modelID, provider: .custom, displayName: displayName.isEmpty ? modelID : displayName, contextWindow: contextWindow, maxOutputTokens: nil, capabilities: ModelCapabilities(vision: supportsVision, tools: false, jsonMode: false), pricing: nil, parameterSupport: .openAIDefault, customBaseURL: URL(string: baseURL) ) catalog.customModels.append(model) dismiss() } } // MARK: - Appearance struct AppearanceSettingsTab: View { @EnvironmentObject private var appearance: AppearanceStore var body: some View { Form { Picker("Theme", selection: $appearance.themeMode) { ForEach(ThemeMode.allCases) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) Picker("Accent", selection: $appearance.accent) { ForEach(AccentChoice.allCases) { choice in Text(choice.displayName).tag(choice) } } VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { HStack { Text("Chat font size") Spacer() Text(String(format: "%.1f pt", appearance.chatFontSize)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) .monospacedDigit() } Slider(value: $appearance.chatFontSize, in: 12...18, step: 0.5) // Live preview VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Text("Preview") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) 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.") .font(ZyquoFont.body(size: appearance.chatFontSize)) .lineSpacing(appearance.chatFontSize * ZyquoFont.bodyLineSpacingFactor) .foregroundStyle(ZyquoColor.textPrimary) .padding(ZyquoSpacing.sm) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) ) } } } .formStyle(.grouped) } } // MARK: - Shortcuts struct ShortcutsSettingsTab: View { private static let shortcuts: [(String, String)] = [ ("New chat", "⌘N"), ("Model switcher / command palette", "⌘K"), ("Search conversations", "⌘F"), ("Send message", "⌘↩"), ("Export conversation", "⌘⇧E"), ("Quick Chat panel", "⌥Space"), ("Settings", "⌘,"), ] var body: some View { Form { ForEach(Self.shortcuts, id: \.0) { name, keys in LabeledContent(name) { Text(keys) .font(ZyquoFont.code(size: 12)) .foregroundStyle(ZyquoColor.textSecondary) } } } .formStyle(.grouped) } } // MARK: - Advanced struct AdvancedSettingsTab: View { @EnvironmentObject private var store: ConversationStore var body: some View { Form { Section("Default system prompt") { TextEditor(text: $store.defaultSystemPrompt) .font(ZyquoFont.body(size: 12.5)) .frame(height: 90) } Section("Data") { LabeledContent("Data folder") { Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([ PersistenceService.shared.rootDirectory ]) } .controlSize(.small) } 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.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } .formStyle(.grouped) } }