// // SettingsView.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import SwiftUI /// Native tabbed settings (720×520): General, Models & Storage, Inference, /// Appearance, Shortcuts, Advanced. struct SettingsView: View { @Environment(AppModel.self) private var app var body: some View { TabView { GeneralSettings() .tabItem { Label("General", systemImage: "gearshape") } ModelsStorageSettings() .tabItem { Label("Models & Storage", systemImage: "internaldrive") } InferenceSettings() .tabItem { Label("Inference", systemImage: "cpu") } AppearanceSettings() .tabItem { Label("Appearance", systemImage: "paintpalette") } ShortcutsSettings() .tabItem { Label("Shortcuts", systemImage: "keyboard") } AdvancedSettings() .tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") } } .frame(width: 720, height: 520) } } private struct GeneralSettings: View { @Environment(AppModel.self) private var app var body: some View { @Bindable var settings = app.settings Form { Picker("Load on launch:", selection: $settings.defaultModelID) { Text("None").tag(String?.none) ForEach(app.store.models) { model in Text(model.name).tag(String?.some(model.repoID)) } } .help("Model loaded automatically when Zyquo Local starts") Toggle("Keep model loaded in the background", isOn: $settings.keepModelLoaded) .help("When off, the model unloads and frees memory when all windows close") Toggle("Show menu bar extra", isOn: $settings.menuBarExtraEnabled) LabeledContent("Default system prompt:") { TextEditor(text: $settings.defaultSystemPrompt) .font(ZyquoTheme.body) .frame(height: 90) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) } } .formStyle(.grouped) .padding(ZyquoTheme.Spacing.m) } } private struct ModelsStorageSettings: View { @Environment(AppModel.self) private var app @State private var tokenVisible = false var body: some View { @Bindable var settings = app.settings Form { LabeledContent("Models folder:") { HStack { Text(app.store.modelsRoot.path) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .truncationMode(.middle) .lineLimit(1) Button("Reveal") { NSWorkspace.shared.activateFileViewerSelecting([app.store.modelsRoot]) } } } LabeledContent("Total usage:") { Text("\(formatBytes(app.store.totalSizeBytes)) across \(app.store.models.count) models") } Section("Hugging Face") { LabeledContent("Access token:") { HStack { Group { if tokenVisible { TextField("hf_…", text: $settings.hfToken) } else { SecureField("hf_…", text: $settings.hfToken) } } .textFieldStyle(.roundedBorder) .frame(width: 260) .onChange(of: settings.hfToken) { app.refreshToken() } Button { tokenVisible.toggle() } label: { Image(systemName: tokenVisible ? "eye.slash" : "eye") } .buttonStyle(.plain) } } Text("Needed only for gated models (Llama, Gemma…). Sent exclusively to huggingface.co.") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) Toggle("Verify file sizes after download", isOn: $settings.autoVerifyDownloads) } } .formStyle(.grouped) .padding(ZyquoTheme.Spacing.m) } } private struct InferenceSettings: View { @Environment(AppModel.self) private var app var body: some View { @Bindable var settings = app.settings Form { Section("Default generation parameters") { ParamsEditor(params: $settings.defaultParams) Text("Used for new conversations; each conversation can override them.") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textTertiary) } Section("Engine") { LabeledContent("GPU cache limit:") { HStack { TextField( "0", value: $settings.gpuCacheLimitMB, format: .number ) .textFieldStyle(.roundedBorder) .frame(width: 90) .onChange(of: settings.gpuCacheLimitMB) { app.applyGPUCacheLimit() } Text("MB (0 = automatic)") .foregroundStyle(ZyquoTheme.textSecondary) } } .help("Caps MLX's buffer cache; lower values return memory to macOS sooner") LabeledContent("Context length cap:") { HStack { TextField("0", value: $settings.contextLengthCap, format: .number) .textFieldStyle(.roundedBorder) .frame(width: 90) Text("tokens (0 = model maximum)") .foregroundStyle(ZyquoTheme.textSecondary) } } } } .formStyle(.grouped) .padding(ZyquoTheme.Spacing.m) } } private struct AppearanceSettings: View { @State private var theme = ThemeStore.shared var body: some View { @Bindable var theme = theme Form { Picker("Theme:", selection: $theme.mode) { ForEach(ThemeStore.Mode.allCases, id: \.self) { Text($0.label).tag($0) } } .pickerStyle(.segmented) LabeledContent("Accent:") { HStack(spacing: ZyquoTheme.Spacing.s) { ForEach(AccentChoice.allCases, id: \.self) { choice in Button { theme.accentChoice = choice } label: { Circle() .fill(choice.color) .frame(width: 22, height: 22) .overlay( Circle().stroke( theme.accentChoice == choice ? ZyquoTheme.textPrimary : .clear, lineWidth: 2 ) .padding(-3) ) } .buttonStyle(.plain) .help(choice.label) } } } Section("Chat text size") { Slider( value: Binding( get: { theme.chatFontSize }, set: { theme.chatFontSize = $0 } ), in: 12...18, step: 0.5 ) { Text("Size") } minimumValueLabel: { Text("A").font(.system(size: 11)) } maximumValueLabel: { Text("A").font(.system(size: 17)) } // Live preview VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) { Text("Live preview — the quick brown fox jumps over the lazy dog.") .font(ZyquoTheme.chatBody) .padding(ZyquoTheme.Spacing.s) .frame(maxWidth: .infinity, alignment: .leading) .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) } } } .formStyle(.grouped) .padding(ZyquoTheme.Spacing.m) } } private struct ShortcutsSettings: View { private let shortcuts: [(String, String)] = [ ("⌘N", "New chat"), ("⌘K", "Model switcher"), ("⌘L", "Model library"), ("⌘F", "Search chats"), ("⌘↩", "Send message"), ("⇧⌘E", "Export conversation"), ("⌥Space", "Quick Chat panel (global)"), ("⌘,", "Settings"), ] var body: some View { Form { ForEach(shortcuts, id: \.0) { pair in LabeledContent(pair.1) { Text(pair.0) .font(ZyquoTheme.code) .padding(.horizontal, ZyquoTheme.Spacing.xs) .padding(.vertical, 2) .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)) } } } .formStyle(.grouped) .padding(ZyquoTheme.Spacing.m) } } private struct AdvancedSettings: View { @Environment(AppModel.self) private var app @State private var importMessage: String? var body: some View { Form { LabeledContent("Data folder:") { Button("Reveal in Finder") { NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.appSupportDirectory]) } } Section("Conversations") { HStack { Button("Export all…") { exportAll() } Button("Import…") { importConversations() } } if let importMessage { Text(importMessage) .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) } } } .formStyle(.grouped) .padding(ZyquoTheme.Spacing.m) } private func exportAll() { let panel = NSSavePanel() panel.allowedContentTypes = [.json] panel.nameFieldStringValue = "Zyquo Local Conversations.json" guard panel.runModal() == .OK, let url = panel.url else { return } let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try? encoder.encode(app.conversations).write(to: url) } private func importConversations() { let panel = NSOpenPanel() panel.allowedContentTypes = [.json] guard panel.runModal() == .OK, let url = panel.url, let data = try? Data(contentsOf: url) else { return } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let imported = try? decoder.decode([Conversation].self, from: data) else { importMessage = "Could not read that file as Zyquo Local conversations." return } var added = 0 for conversation in imported where !app.conversations.contains(where: { $0.id == conversation.id }) { app.conversations.append(conversation) PersistenceService.save(conversation) added += 1 } app.conversations.sort { $0.updatedAt > $1.updatedAt } importMessage = "Imported \(added) conversation\(added == 1 ? "" : "s")." } }