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%
7.0 KB · 207 lines swift
Raw Blame History
1//2//  QuickChatPanel.swift3//  Zyquo Local4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//89import AppKit10import Carbon.HIToolbox11import SwiftUI1213/// Spotlight-style floating Quick Chat (⌥Space): one prompt, one streamed14/// answer from the currently loaded model; offers one-click load of the15/// last-used model when none is loaded.16@MainActor17final class QuickChatPanelController {18    static let shared = QuickChatPanelController()1920    private var panel: NSPanel?21    private var hotKeyRef: EventHotKeyRef?22    private weak var app: AppModel?2324    func setup(app: AppModel) {25        self.app = app26        registerHotKey()27    }2829    func toggle() {30        if let panel, panel.isVisible {31            panel.orderOut(nil)32            return33        }34        show()35    }3637    private func show() {38        guard let app else { return }39        let panel: NSPanel40        if let existing = self.panel {41            panel = existing42        } else {43            panel = NSPanel(44                contentRect: NSRect(x: 0, y: 0, width: 560, height: 120),45                styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView],46                backing: .buffered, defer: false47            )48            panel.titleVisibility = .hidden49            panel.titlebarAppearsTransparent = true50            panel.isMovableByWindowBackground = true51            panel.level = .floating52            panel.collectionBehavior = [.canJoinAllSpaces, .transient]53            panel.isFloatingPanel = true54            panel.becomesKeyOnlyIfNeeded = false55            panel.hidesOnDeactivate = false56            panel.backgroundColor = .clear57            panel.isOpaque = false58            let host = NSHostingView(59                rootView: QuickChatView(close: { [weak panel] in panel?.orderOut(nil) })60                    .environment(app)61            )62            panel.contentView = host63            self.panel = panel64        }65        if let screen = NSScreen.main {66            let frame = screen.visibleFrame67            let size = panel.frame.size68            panel.setFrameOrigin(NSPoint(69                x: frame.midX - size.width / 2,70                y: frame.midY + frame.height * 0.1271            ))72        }73        panel.makeKeyAndOrderFront(nil)74        NSApp.activate(ignoringOtherApps: true)75    }7677    /// Carbon global hotkey: ⌥Space. Works without accessibility permission.78    private func registerHotKey() {79        guard hotKeyRef == nil else { return }80        let hotKeyID = EventHotKeyID(signature: OSType(0x5A51_4348), id: 1)  // "ZQCH"81        var eventType = EventTypeSpec(82            eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))83        InstallEventHandler(84            GetApplicationEventTarget(),85            { _, _, _ in86                Task { @MainActor in87                    QuickChatPanelController.shared.toggle()88                }89                return noErr90            },91            1, &eventType, nil, nil92        )93        RegisterEventHotKey(94            UInt32(kVK_Space), UInt32(optionKey), hotKeyID,95            GetApplicationEventTarget(), 0, &hotKeyRef96        )97    }98}99100/// The panel content.101struct QuickChatView: View {102    let close: () -> Void103    @Environment(AppModel.self) private var app104    @State private var prompt = ""105    @State private var response = ""106    @State private var generating = false107    @FocusState private var focused: Bool108109    var body: some View {110        VStack(spacing: 0) {111            HStack(spacing: ZyquoTheme.Spacing.s) {112                ZyquoGlyph(size: 18)113                TextField(fieldHint, text: $prompt)114                    .textFieldStyle(.plain)115                    .font(.system(size: 17))116                    .focused($focused)117                    .onSubmit(submit)118                    .disabled(app.loadedModelID == nil)119                if generating {120                    ProgressView().controlSize(.small)121                }122            }123            .padding(ZyquoTheme.Spacing.m)124125            if app.loadedModelID == nil {126                loadHint127            }128129            if !response.isEmpty {130                Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline)131                ScrollView {132                    MarkdownText(response)133                        .padding(ZyquoTheme.Spacing.m)134                        .frame(maxWidth: .infinity, alignment: .leading)135                }136                .frame(maxHeight: 340)137            }138        }139        .frame(width: 560)140        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l))141        .overlay(142            RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)143                .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline)144        )145        .onAppear { focused = true }146        .onExitCommand(perform: close)147    }148149    private var fieldHint: String {150        if let repoID = app.loadedModelID {151            return "Ask \(shortModelName(repoID))…"152        }153        return "No model loaded"154    }155156    @ViewBuilder157    private var loadHint: some View {158        if let lastUsed = app.store.models.first {159            HStack {160                Text("Load \(lastUsed.name) to use Quick Chat")161                    .font(ZyquoTheme.caption)162                    .foregroundStyle(ZyquoTheme.textSecondary)163                Spacer()164                Button("Load") {165                    Task { await app.loadModel(repoID: lastUsed.repoID) }166                }167                .buttonStyle(.borderedProminent)168                .tint(ZyquoTheme.accent)169                .controlSize(.small)170            }171            .padding(.horizontal, ZyquoTheme.Spacing.m)172            .padding(.bottom, ZyquoTheme.Spacing.s)173        } else {174            Text("Download a model in the Library first.")175                .font(ZyquoTheme.caption)176                .foregroundStyle(ZyquoTheme.textSecondary)177                .padding(.bottom, ZyquoTheme.Spacing.s)178        }179    }180181    private func submit() {182        let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines)183        guard !text.isEmpty, !generating, app.loadedModelID != nil else { return }184        response = ""185        generating = true186        Task {187            do {188                // Ephemeral session; the main conversation session is189                // restored on its next message.190                let temp = Conversation(params: app.settings.defaultParams)191                try await app.engine.startSession(conversation: temp)192                var parser = ThinkTagParser()193                let events = try await app.engine.generate(prompt: text, params: temp.params)194                for try await event in events {195                    if case .token(let t) = event {196                        let (visible, _, _) = parser.consume(t)197                        if !visible.isEmpty { response += visible }198                    }199                }200            } catch {201                response = "*\(error.localizedDescription)*"202            }203            generating = false204        }205    }206}207