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// 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