HuggingChat-Mac/AppDelegate.swift (212 lines of code) (raw):
//
// AppDelegate.swift
// HuggingChat-Mac
//
// Created by Cyril Zakka on 8/16/24.
//
import Foundation
import AppKit
import MLXLLM
import Combine
import KeyboardShortcuts
import SwiftUI
import WhisperKit
import AVFoundation
extension KeyboardShortcuts.Name {
static let showFloatingPanel = Self("showFloatingPanel", default: .init(.return, modifiers: [.command, .shift]))
static let toggleLocalGeneration = Self("toggleLocalGeneration", default: .init(.backslash, modifiers: [.command, .shift]))
static let showTranscriptionPanel = Self("showTranscriptionPanel", default: .init(.space, modifiers: [.command, .shift]))
}
class AppDelegate: NSObject, NSApplicationDelegate {
@AppStorage("hideDock") private var hideDock: Bool = false
@AppStorage("localModel") private var selectedLocalModel: String = "None"
@AppStorage("selectedAudioModel") private var selectedAudioModel: String = "None"
@AppStorage("selectedAudioInput") private var selectedAudioInput: String = "None"
@AppStorage("smartDictation") private var smartDictation: Bool = false
@AppStorage("isLocalGeneration") private var isLocalGeneration: Bool = false
@AppStorage("useContext") private var useContext: Bool = false
@Environment(\.openSettings) private var openSettings
@State var modelManager = ModelManager()
@State var audioModelManager = AudioModelManager()
@State var conversationModel = ConversationViewModel()
@State var menuModel = MenuViewModel()
@State var themeEngine = ThemingEngine()
var newEntryPanel: FloatingPanel!
var transcriptionPanel: ToastPanel!
var statusBar: NSStatusBar!
var statusBarItem: NSStatusItem!
private var recordingTimer: Timer?
private var isKeyDown = false
private var cancellable: AnyCancellable?
func applicationDidFinishLaunching(_ notification: Notification) {
createFloatingPanel()
newEntryPanel.center()
createTranscriptionPanel()
// Set keyboard shortcut
KeyboardShortcuts.onKeyUp(for: .showFloatingPanel, action: {
self.toggleFloatingPanel()
})
KeyboardShortcuts.onKeyUp(for: .toggleLocalGeneration, action: {
if self.selectedLocalModel != "None" {
self.isLocalGeneration.toggle()
}
})
KeyboardShortcuts.onKeyDown(for: .showTranscriptionPanel, action: {
self.handleKeyDown()
})
KeyboardShortcuts.onKeyUp(for: .showTranscriptionPanel, action: {
self.handleKeyUp()
})
// Check hide dock status
NSApp.setActivationPolicy(hideDock ? .accessory : .regular)
// Setup local model if needed
if selectedLocalModel != "None" {
if let selectedLocalModel = modelManager.availableModels.first(where: { $0.displayName == selectedLocalModel }) {
Task {
await modelManager.localModelDidChange(to: selectedLocalModel)
}
}
}
// Setup transcription model if needed
audioModelManager.setupMicrophone()
if selectedAudioModel != "None" {
audioModelManager.loadModel(selectedAudioModel)
}
createMenuBarItem()
}
private func createFloatingPanel() {
let contentView = ChatView()
.environment(themeEngine)
.environment(modelManager)
.environment(menuModel)
.environment(conversationModel)
.environment(audioModelManager)
.frame(minWidth: 400, idealWidth: 450, maxWidth: 600)
.frame(minHeight: 300, idealHeight: 300)
// .fixedSize(horizontal: true, vertical: false)
// .edgesIgnoringSafeArea(.top)
// .frame(width: 500) // TODO: Should be relative to screen size
newEntryPanel = FloatingPanel(contentRect: NSRect(x: 0, y: 0, width: 400, height: 400), backing: .buffered, defer: false)
newEntryPanel.contentView = NSHostingView(rootView: contentView)
// newEntryPanel.contentView?.clipsToBounds = false
}
private func createTranscriptionPanel() {
let contentView = TranscriptionView()
.environment(modelManager)
.environment(conversationModel)
.environment(audioModelManager)
let screenFrame = NSScreen.main?.visibleFrame ?? NSRect.zero
let panelWidth: CGFloat = 95
let panelHeight: CGFloat = 75
let topPadding: CGFloat = 10
let xPosition = (screenFrame.width - panelWidth) / 2 + screenFrame.minX
let yPosition = screenFrame.maxY - panelHeight - topPadding
let panelFrame = NSRect(x: xPosition, y: yPosition, width: panelWidth, height: panelHeight)
transcriptionPanel = ToastPanel(contentRect: panelFrame, backing: .buffered, defer: false)
transcriptionPanel.contentView = NSHostingView(rootView: contentView)
}
private func createMenuBarItem() {
statusBar = NSStatusBar.system
statusBarItem = statusBar.statusItem(withLength: NSStatusItem.squareLength)
if let button = statusBarItem.button {
button.image = NSImage(named: "huggy")
button.target = self
button.action = #selector(handleStatusItemClick)
button.sendAction(on: [.leftMouseDown, .rightMouseUp])
}
}
@objc private func toggleFloatingPanel() {
if self.newEntryPanel.isVisible {
self.newEntryPanel.orderOut(nil)
} else {
self.newEntryPanel.makeKeyAndOrderFront(nil)
if useContext {
conversationModel.fetchContext()
}
// NSApp.activate(ignoringOtherApps: true)
}
}
private func handleKeyDown() {
isKeyDown = true
// Add a small delay, otherwise won't cancel properly on key up
recordingTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: false) { [weak self] _ in
if self?.isKeyDown == true {
self?.startRecording()
}
}
}
private func handleKeyUp() {
isKeyDown = false
recordingTimer?.invalidate()
recordingTimer = nil
if self.transcriptionPanel.isVisible {
self.stopRecording()
}
}
@objc private func startRecording() {
if selectedAudioModel != "None" && selectedAudioInput != "None" && audioModelManager.modelState == .loaded {
self.transcriptionPanel.orderFront(nil)
if self.transcriptionPanel.isVisible {
audioModelManager.resetState()
audioModelManager.startRecording(true, source: .transcriptionView)
}
}
}
@objc private func stopRecording() {
audioModelManager.stopRecording(false)
self.transcriptionPanel.orderOut(nil)
}
private func showContextMenu() {
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "About", action: #selector(openAboutWindow), keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Settings...", action: #selector(openPreferencesWindow), keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q"))
statusBarItem.menu = menu
statusBarItem.button?.performClick(nil)
statusBarItem.menu = nil
}
@objc private func handleStatusItemClick(sender: NSStatusBarButton) {
if let event = NSApp.currentEvent, event.isRightClickUp {
showContextMenu()
} else {
toggleFloatingPanel()
}
}
// Sometimes we do things we aren't proud of.
@objc private func openPreferencesWindow() {
// openSettings()
let kAppMenuInternalIdentifier = "app"
let kSettingsLocalizedStringKey = "Settings\\U2026";
if let internalItemAction = NSApp.mainMenu?.item(
withInternalIdentifier: kAppMenuInternalIdentifier
)?.submenu?.item(
withLocalizedTitle: kSettingsLocalizedStringKey
)?.internalItemAction {
internalItemAction();
return;
}
}
@objc private func openAboutWindow() {
// I would much prefer this but it results in a runtime warning:
// openWindow(id: "about")
for window in NSApplication.shared.windows {
if window.identifier?.rawValue == "about" {
window.makeKeyAndOrderFront(nil)
return
}
}
let aboutView = AboutView()
.frame(width: 450, height: 175)
.toolbarBackground(.hidden, for: .windowToolbar)
let aboutWindow = NSPanel(
contentRect: NSRect(x: 100, y: 100, width: 450, height: 175),
styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel],
backing: .buffered,
defer: false
)
aboutWindow.identifier = NSUserInterfaceItemIdentifier("about")
aboutWindow.title = "About"
aboutWindow.isReleasedWhenClosed = false
aboutWindow.center()
aboutWindow.isOpaque = false
// Hide the title bar
aboutWindow.titlebarAppearsTransparent = true
aboutWindow.titleVisibility = .hidden
aboutWindow.isMovableByWindowBackground = true
let visualEffectView = NSVisualEffectView()
visualEffectView.material = .menu
visualEffectView.state = .active
visualEffectView.blendingMode = .behindWindow
let hostingView = NSHostingView(rootView: aboutView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
// Add visual effect view and hosting view to the window
aboutWindow.contentView = visualEffectView
visualEffectView.addSubview(hostingView)
// Add constraints
NSLayoutConstraint.activate([
hostingView.topAnchor.constraint(equalTo: visualEffectView.topAnchor),
hostingView.leadingAnchor.constraint(equalTo: visualEffectView.leadingAnchor),
hostingView.trailingAnchor.constraint(equalTo: visualEffectView.trailingAnchor),
hostingView.bottomAnchor.constraint(equalTo: visualEffectView.bottomAnchor)
])
aboutWindow.makeKeyAndOrderFront(nil)
}
}