SPB Git

spb/zyquo-cloud Public MIT

Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.

Swift 97.4% Shell 1.7% Makefile 1%
8.6 KB · 234 lines swift
Raw Blame History
1//2//  QuickChatPanel.swift3//  Zyquo Cloud4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Global Quick Chat (⌥Space): floating Spotlight-style panel — 640pt wide,9//  radius 14, prominent shadow, single input + model chip; the answer expands10//  below; ESC dismisses. Appears on the active screen at 30% height.11//1213import SwiftUI1415/// Manages the floating NSPanel hosting QuickChatView and the global hotkey.16@MainActor17final class QuickChatController {18    private var panel: NSPanel?19    private var hotKeyMonitor: Any?20    private let environment: AppEnvironment2122    init(environment: AppEnvironment) {23        self.environment = environment24        installHotKey()25    }2627    private func installHotKey() {28        // ⌥Space, global. Requires no accessibility permission for local29        // monitor; global monitor works while other apps are frontmost.30        hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in31            guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return }32            Task { @MainActor in self?.toggle() }33        }34        NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in35            if event.keyCode == 49, event.modifierFlags.contains(.option) {36                Task { @MainActor in self?.toggle() }37                return nil38            }39            return event40        }41    }4243    func toggle() {44        if let panel, panel.isVisible {45            panel.orderOut(nil)46            return47        }48        show()49    }5051    func show() {52        let panel = self.panel ?? makePanel()53        self.panel = panel54        positionOnActiveScreen(panel)55        panel.makeKeyAndOrderFront(nil)56        NSApp.activate(ignoringOtherApps: true)57    }5859    private func makePanel() -> NSPanel {60        let hosting = NSHostingView(61            rootView: QuickChatView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) })62                .environmentObject(environment.catalog)63                .environmentObject(environment.vault)64                .environmentObject(environment.appearance)65                .environmentObject(environment.conversations)66        )67        let panel = KeyablePanel(68            contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickChatWidth, height: 120),69            styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled],70            backing: .buffered,71            defer: false72        )73        panel.titleVisibility = .hidden74        panel.titlebarAppearsTransparent = true75        panel.isMovableByWindowBackground = true76        panel.level = .floating77        panel.collectionBehavior = [.canJoinAllSpaces, .transient]78        panel.isOpaque = false79        panel.backgroundColor = .clear80        panel.hidesOnDeactivate = false81        panel.contentView = hosting82        return panel83    }8485    private func positionOnActiveScreen(_ panel: NSPanel) {86        let screen = NSScreen.main ?? NSScreen.screens[0]87        let frame = screen.visibleFrame88        let size = panel.frame.size89        let x = frame.midX - size.width / 290        let y = frame.maxY - frame.height * 0.30 - size.height91        panel.setFrameOrigin(NSPoint(x: x, y: y))92    }93}9495/// NSPanel subclass that can become key despite .nonactivatingPanel.96final class KeyablePanel: NSPanel {97    override var canBecomeKey: Bool { true }98    override func cancelOperation(_ sender: Any?) {99        orderOut(nil)100    }101}102103struct QuickChatView: View {104    var onDismiss: () -> Void105106    @EnvironmentObject private var catalog: ModelCatalog107    @EnvironmentObject private var vault: KeyVaultStore108    @EnvironmentObject private var appearance: AppearanceStore109    @EnvironmentObject private var conversations: ConversationStore110111    @State private var input = ""112    @State private var answer = ""113    @State private var reasoning = ""114    @State private var isStreaming = false115    @State private var errorText: String?116    @State private var model: AIModel?117    @State private var streamTask: Task<Void, Never>?118    @FocusState private var focused: Bool119120    var body: some View {121        VStack(spacing: 0) {122            HStack(spacing: ZyquoSpacing.sm) {123                CloudZGlyph(size: 24)124                TextField("Ask anything…", text: $input)125                    .textFieldStyle(.plain)126                    .font(ZyquoFont.body(size: 16))127                    .focused($focused)128                    .onSubmit(ask)129                ModelChipView(model: model ?? catalog.defaultModel) { chosen in130                    model = chosen131                }132                if isStreaming {133                    Button {134                        streamTask?.cancel()135                    } label: {136                        Image(systemName: "stop.fill")137                            .foregroundStyle(ZyquoColor.danger)138                    }139                    .buttonStyle(.plain)140                }141            }142            .padding(ZyquoSpacing.md)143144            if !answer.isEmpty || isStreaming || errorText != nil {145                ZyquoHairline()146                ScrollView {147                    VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {148                        if let errorText {149                            Text(errorText)150                                .font(ZyquoFont.body())151                                .foregroundStyle(ZyquoColor.danger)152                        } else {153                            MarkdownView(text: answer, fontSize: appearance.chatFontSize)154                            if isStreaming { StreamingCaret() }155                        }156                    }157                    .frame(maxWidth: .infinity, alignment: .leading)158                    .padding(ZyquoSpacing.md)159                }160                .frame(maxHeight: 360)161                HStack {162                    Spacer()163                    Button("Continue in Zyquo Cloud") { continueInApp() }164                        .controlSize(.small)165                        .disabled(answer.isEmpty)166                }167                .padding(.horizontal, ZyquoSpacing.md)168                .padding(.bottom, ZyquoSpacing.xs)169            }170        }171        .frame(width: ZyquoMetrics.quickChatWidth)172        .background(173            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)174                .fill(ZyquoColor.surface)175        )176        .overlay(177            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)178                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)179        )180        .zyquoSoftShadow()181        .onAppear { focused = true }182        .onExitCommand { onDismiss() }183    }184185    private func ask() {186        let question = input.trimmingCharacters(in: .whitespacesAndNewlines)187        guard !question.isEmpty, !isStreaming else { return }188        guard let target = model ?? catalog.defaultModel else { return }189        answer = ""190        reasoning = ""191        errorText = nil192        isStreaming = true193        streamTask = Task {194            do {195                let key = try vault.apiKey(for: target.provider)196                let client = ProviderRegistry.client(for: target)197                let request = ChatRequest(198                    model: target,199                    systemPrompt: "Answer concisely.",200                    messages: [Message(role: .user, text: question)],201                    parameters: ChatParameters()202                )203                for try await event in client.streamChat(request, apiKey: key) {204                    if Task.isCancelled { break }205                    switch event {206                    case .textDelta(let delta): answer += delta207                    case .reasoningDelta(let delta): reasoning += delta208                    default: break209                    }210                }211            } catch {212                errorText = error.localizedDescription213            }214            isStreaming = false215        }216    }217218    /// Moves the exchange into a full conversation in the main window.219    private func continueInApp() {220        let conversation = conversations.newConversation(model: model ?? catalog.defaultModel)221        guard var updated = conversations.conversations.first(where: { $0.id == conversation.id }) else { return }222        var question = Message(role: .user, text: input)223        question.provider = updated.provider224        question.modelID = updated.modelID225        var reply = Message(role: .assistant, text: answer, reasoning: reasoning.isEmpty ? nil : reasoning)226        reply.provider = updated.provider227        reply.modelID = updated.modelID228        updated.messages = [question, reply]229        conversations.update(updated)230        onDismiss()231        NSApp.activate(ignoringOtherApps: true)232    }233}234