SPB Git

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%
12.2 KB · 326 lines swift
Raw Blame History
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