spb/zyquo-local Public MIT
Native macOS AI chat that runs LLMs 100% locally on Apple Silicon with MLX — no cloud, no API keys.
Swift 97.2%
Shell 1.8%
Makefile 1%
1//2// SettingsView.swift3// Zyquo Local4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//89import SwiftUI1011/// Native tabbed settings (720×520): General, Models & Storage, Inference,12/// Appearance, Shortcuts, Advanced.13struct SettingsView: View {14 @Environment(AppModel.self) private var app1516 var body: some View {17 TabView {18 GeneralSettings()19 .tabItem { Label("General", systemImage: "gearshape") }20 ModelsStorageSettings()21 .tabItem { Label("Models & Storage", systemImage: "internaldrive") }22 InferenceSettings()23 .tabItem { Label("Inference", systemImage: "cpu") }24 AppearanceSettings()25 .tabItem { Label("Appearance", systemImage: "paintpalette") }26 ShortcutsSettings()27 .tabItem { Label("Shortcuts", systemImage: "keyboard") }28 AdvancedSettings()29 .tabItem { Label("Advanced", systemImage: "wrench.and.screwdriver") }30 }31 .frame(width: 720, height: 520)32 }33}3435private struct GeneralSettings: View {36 @Environment(AppModel.self) private var app3738 var body: some View {39 @Bindable var settings = app.settings40 Form {41 Picker("Load on launch:", selection: $settings.defaultModelID) {42 Text("None").tag(String?.none)43 ForEach(app.store.models) { model in44 Text(model.name).tag(String?.some(model.repoID))45 }46 }47 .help("Model loaded automatically when Zyquo Local starts")4849 Toggle("Keep model loaded in the background", isOn: $settings.keepModelLoaded)50 .help("When off, the model unloads and frees memory when all windows close")5152 Toggle("Show menu bar extra", isOn: $settings.menuBarExtraEnabled)5354 LabeledContent("Default system prompt:") {55 TextEditor(text: $settings.defaultSystemPrompt)56 .font(ZyquoTheme.body)57 .frame(height: 90)58 .overlay(59 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s)60 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)61 )62 }63 }64 .formStyle(.grouped)65 .padding(ZyquoTheme.Spacing.m)66 }67}6869private struct ModelsStorageSettings: View {70 @Environment(AppModel.self) private var app71 @State private var tokenVisible = false7273 var body: some View {74 @Bindable var settings = app.settings75 Form {76 LabeledContent("Models folder:") {77 HStack {78 Text(app.store.modelsRoot.path)79 .font(ZyquoTheme.caption)80 .foregroundStyle(ZyquoTheme.textSecondary)81 .truncationMode(.middle)82 .lineLimit(1)83 Button("Reveal") {84 NSWorkspace.shared.activateFileViewerSelecting([app.store.modelsRoot])85 }86 }87 }88 LabeledContent("Total usage:") {89 Text("\(formatBytes(app.store.totalSizeBytes)) across \(app.store.models.count) models")90 }9192 Section("Hugging Face") {93 LabeledContent("Access token:") {94 HStack {95 Group {96 if tokenVisible {97 TextField("hf_…", text: $settings.hfToken)98 } else {99 SecureField("hf_…", text: $settings.hfToken)100 }101 }102 .textFieldStyle(.roundedBorder)103 .frame(width: 260)104 .onChange(of: settings.hfToken) { app.refreshToken() }105106 Button {107 tokenVisible.toggle()108 } label: {109 Image(systemName: tokenVisible ? "eye.slash" : "eye")110 }111 .buttonStyle(.plain)112 }113 }114 Text("Needed only for gated models (Llama, Gemma…). Sent exclusively to huggingface.co.")115 .font(ZyquoTheme.caption)116 .foregroundStyle(ZyquoTheme.textTertiary)117118 Toggle("Verify file sizes after download", isOn: $settings.autoVerifyDownloads)119 }120 }121 .formStyle(.grouped)122 .padding(ZyquoTheme.Spacing.m)123 }124}125126private struct InferenceSettings: View {127 @Environment(AppModel.self) private var app128129 var body: some View {130 @Bindable var settings = app.settings131 Form {132 Section("Default generation parameters") {133 ParamsEditor(params: $settings.defaultParams)134 Text("Used for new conversations; each conversation can override them.")135 .font(ZyquoTheme.caption)136 .foregroundStyle(ZyquoTheme.textTertiary)137 }138 Section("Engine") {139 LabeledContent("GPU cache limit:") {140 HStack {141 TextField(142 "0",143 value: $settings.gpuCacheLimitMB,144 format: .number145 )146 .textFieldStyle(.roundedBorder)147 .frame(width: 90)148 .onChange(of: settings.gpuCacheLimitMB) { app.applyGPUCacheLimit() }149 Text("MB (0 = automatic)")150 .foregroundStyle(ZyquoTheme.textSecondary)151 }152 }153 .help("Caps MLX's buffer cache; lower values return memory to macOS sooner")154 LabeledContent("Context length cap:") {155 HStack {156 TextField("0", value: $settings.contextLengthCap, format: .number)157 .textFieldStyle(.roundedBorder)158 .frame(width: 90)159 Text("tokens (0 = model maximum)")160 .foregroundStyle(ZyquoTheme.textSecondary)161 }162 }163 }164 }165 .formStyle(.grouped)166 .padding(ZyquoTheme.Spacing.m)167 }168}169170private struct AppearanceSettings: View {171 @State private var theme = ThemeStore.shared172173 var body: some View {174 @Bindable var theme = theme175 Form {176 Picker("Theme:", selection: $theme.mode) {177 ForEach(ThemeStore.Mode.allCases, id: \.self) { Text($0.label).tag($0) }178 }179 .pickerStyle(.segmented)180181 LabeledContent("Accent:") {182 HStack(spacing: ZyquoTheme.Spacing.s) {183 ForEach(AccentChoice.allCases, id: \.self) { choice in184 Button {185 theme.accentChoice = choice186 } label: {187 Circle()188 .fill(choice.color)189 .frame(width: 22, height: 22)190 .overlay(191 Circle().stroke(192 theme.accentChoice == choice ? ZyquoTheme.textPrimary : .clear,193 lineWidth: 2194 )195 .padding(-3)196 )197 }198 .buttonStyle(.plain)199 .help(choice.label)200 }201 }202 }203204 Section("Chat text size") {205 Slider(206 value: Binding(207 get: { theme.chatFontSize },208 set: { theme.chatFontSize = $0 }209 ),210 in: 12...18, step: 0.5211 ) {212 Text("Size")213 } minimumValueLabel: {214 Text("A").font(.system(size: 11))215 } maximumValueLabel: {216 Text("A").font(.system(size: 17))217 }218 // Live preview219 VStack(alignment: .leading, spacing: ZyquoTheme.Spacing.xs) {220 Text("Live preview — the quick brown fox jumps over the lazy dog.")221 .font(ZyquoTheme.chatBody)222 .padding(ZyquoTheme.Spacing.s)223 .frame(maxWidth: .infinity, alignment: .leading)224 .background(ZyquoTheme.surface, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m))225 .overlay(226 RoundedRectangle(cornerRadius: ZyquoTheme.Radius.m)227 .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)228 )229 }230 }231 }232 .formStyle(.grouped)233 .padding(ZyquoTheme.Spacing.m)234 }235}236237private struct ShortcutsSettings: View {238 private let shortcuts: [(String, String)] = [239 ("⌘N", "New chat"),240 ("⌘K", "Model switcher"),241 ("⌘L", "Model library"),242 ("⌘F", "Search chats"),243 ("⌘↩", "Send message"),244 ("⇧⌘E", "Export conversation"),245 ("⌥Space", "Quick Chat panel (global)"),246 ("⌘,", "Settings"),247 ]248249 var body: some View {250 Form {251 ForEach(shortcuts, id: \.0) { pair in252 LabeledContent(pair.1) {253 Text(pair.0)254 .font(ZyquoTheme.code)255 .padding(.horizontal, ZyquoTheme.Spacing.xs)256 .padding(.vertical, 2)257 .background(ZyquoTheme.surfaceSecondary, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.s))258 }259 }260 }261 .formStyle(.grouped)262 .padding(ZyquoTheme.Spacing.m)263 }264}265266private struct AdvancedSettings: View {267 @Environment(AppModel.self) private var app268 @State private var importMessage: String?269270 var body: some View {271 Form {272 LabeledContent("Data folder:") {273 Button("Reveal in Finder") {274 NSWorkspace.shared.activateFileViewerSelecting([PersistenceService.appSupportDirectory])275 }276 }277 Section("Conversations") {278 HStack {279 Button("Export all…") { exportAll() }280 Button("Import…") { importConversations() }281 }282 if let importMessage {283 Text(importMessage)284 .font(ZyquoTheme.caption)285 .foregroundStyle(ZyquoTheme.textSecondary)286 }287 }288 }289 .formStyle(.grouped)290 .padding(ZyquoTheme.Spacing.m)291 }292293 private func exportAll() {294 let panel = NSSavePanel()295 panel.allowedContentTypes = [.json]296 panel.nameFieldStringValue = "Zyquo Local Conversations.json"297 guard panel.runModal() == .OK, let url = panel.url else { return }298 let encoder = JSONEncoder()299 encoder.dateEncodingStrategy = .iso8601300 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]301 try? encoder.encode(app.conversations).write(to: url)302 }303304 private func importConversations() {305 let panel = NSOpenPanel()306 panel.allowedContentTypes = [.json]307 guard panel.runModal() == .OK, let url = panel.url,308 let data = try? Data(contentsOf: url)309 else { return }310 let decoder = JSONDecoder()311 decoder.dateDecodingStrategy = .iso8601312 guard let imported = try? decoder.decode([Conversation].self, from: data) else {313 importMessage = "Could not read that file as Zyquo Local conversations."314 return315 }316 var added = 0317 for conversation in imported where !app.conversations.contains(where: { $0.id == conversation.id }) {318 app.conversations.append(conversation)319 PersistenceService.save(conversation)320 added += 1321 }322 app.conversations.sort { $0.updatedAt > $1.updatedAt }323 importMessage = "Imported \(added) conversation\(added == 1 ? "" : "s")."324 }325}326