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%
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