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%
4.9 KB · 142 lines swift
Raw Blame History
1//2//  ZyquoLocalApp.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import SwiftUI1011struct ZyquoLocalApp: App {12    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate13    @State private var app = AppModel()14    @Environment(\.openWindow) private var openWindow1516    var body: some Scene {17        WindowGroup {18            if HardwareGate.isAppleSilicon {19                RootView()20                    .environment(app)21                    .frame(minWidth: 980, minHeight: 640)22                    .onAppear {23                        QuickChatPanelController.shared.setup(app: app)24                    }25            } else {26                UnsupportedHardwareView()27            }28        }29        .defaultSize(width: 1240, height: 800)30        .windowStyle(.hiddenTitleBar)31        .windowToolbarStyle(.unifiedCompact)32        .commands {33            CommandGroup(replacing: .newItem) {34                Button("New Chat") { app.newConversation() }35                    .keyboardShortcut("n", modifiers: .command)36            }37            CommandMenu("Model") {38                Button("Model Switcher…") {39                    NotificationCenter.default.post(name: .zyquoOpenModelSwitcher, object: nil)40                }41                .keyboardShortcut("k", modifiers: .command)42                Button("Library") { app.route = .library }43                    .keyboardShortcut("l", modifiers: .command)44                Divider()45                Button("Compare Models…") { openWindow(id: "compare") }46                Button("Quick Chat") { QuickChatPanelController.shared.toggle() }47                    .keyboardShortcut(.space, modifiers: .option)48                Divider()49                Button("Unload Model") { Task { await app.unloadModel() } }50                    .disabled(app.loadedModelID == nil)51            }52            CommandGroup(after: .textEditing) {53                Button("Search Chats") {54                    NotificationCenter.default.post(name: .zyquoFocusSearch, object: nil)55                }56                .keyboardShortcut("f", modifiers: .command)57            }58            CommandGroup(replacing: .importExport) {59                Button("Export Conversation…") {60                    NotificationCenter.default.post(name: .zyquoExportConversation, object: nil)61                }62                .keyboardShortcut("e", modifiers: [.command, .shift])63            }64        }6566        Window("Compare Models", id: "compare") {67            CompareView()68                .environment(app)69        }70        .defaultSize(width: 1100, height: 700)7172        Settings {73            SettingsView()74                .environment(app)75        }7677        MenuBarExtra(isInserted: menuBarBinding) {78            MenuBarExtraContent()79                .environment(app)80        } label: {81            Image(nsImage: menuBarIcon)82        }83        .menuBarExtraStyle(.menu)84    }8586    private var menuBarBinding: Binding<Bool> {87        Binding(88            get: { app.settings.menuBarExtraEnabled },89            set: { app.settings.menuBarExtraEnabled = $0 }90        )91    }9293    private var menuBarIcon: NSImage {94        if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "png"),95            let image = NSImage(contentsOf: url)96        {97            image.isTemplate = true98            image.size = NSSize(width: 18, height: 18)99            return image100        }101        let fallback = NSImage(102            systemSymbolName: "bolt.circle", accessibilityDescription: "Zyquo Local")!103        fallback.isTemplate = true104        return fallback105    }106}107108/// Menu bar extra: state at a glance + quick actions.109struct MenuBarExtraContent: View {110    @Environment(AppModel.self) private var app111    @Environment(\.openWindow) private var openWindow112113    var body: some View {114        Group {115            if let repoID = app.loadedModelID {116                Text("Loaded: \(shortModelName(repoID))")117            } else {118                Text("No model loaded")119            }120            if app.downloads.activeCount > 0 {121                Text("Downloading \(app.downloads.activeCount) model\(app.downloads.activeCount == 1 ? "" : "s") · \(Int(app.downloads.overallFraction * 100))%")122            }123            Divider()124            Button("Quick Chat  ⌥Space") { QuickChatPanelController.shared.toggle() }125            Button("Open Zyquo Local") {126                NSApp.activate(ignoringOtherApps: true)127                NSApp.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil)128            }129            Divider()130            Button("Quit Zyquo Local") { NSApp.terminate(nil) }131        }132    }133}134135final class AppDelegate: NSObject, NSApplicationDelegate {136    func applicationDidFinishLaunching(_ notification: Notification) {137        // Proper foreground activation when launched from a terminal.138        NSApp.setActivationPolicy(.regular)139        NSApp.activate(ignoringOtherApps: true)140    }141}142