feat: 菜单栏切换模式,统一使用 Fn 键触发

This commit is contained in:
JOJO 2026-04-24 18:49:13 +08:00
parent 67b3024bda
commit a12258e051
5 changed files with 110 additions and 88 deletions

View File

@ -12,7 +12,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var isRecording = false private var isRecording = false
private var finalTranscription: String = "" private var finalTranscription: String = ""
private var currentMode: LLMMode = .input
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory) NSApp.setActivationPolicy(.accessory)
@ -74,23 +73,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return return
} }
// Fn // Fn
keyMonitor.onFnDown = { [weak self] in keyMonitor.onFnDown = { [weak self] in
self?.currentMode = .input
self?.startRecording() self?.startRecording()
} }
keyMonitor.onFnUp = { [weak self] in keyMonitor.onFnUp = { [weak self] in
self?.stopRecording() self?.stopRecording()
} }
// Control AI
keyMonitor.onControlDown = { [weak self] in
self?.currentMode = .assist
self?.startRecording()
}
keyMonitor.onControlUp = { [weak self] in
self?.stopRecording()
}
} }
// MARK: - Recording // MARK: - Recording
@ -103,7 +92,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
recordingPanel.showAnimated() recordingPanel.showAnimated()
// //
let promptText = currentMode == .input ? "Listening..." : "Ask AI..." let promptText = Config.shared.triggerMode == .input ? "Listening..." : "Ask AI..."
recordingPanel.updateText(promptText) recordingPanel.updateText(promptText)
let language = Config.shared.language.rawValue let language = Config.shared.language.rawValue
@ -155,14 +144,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return return
} }
let config = Config.shared.getLLMConfig(for: currentMode) // triggerMode
let mode = Config.shared.triggerMode
let config = mode == .input ? Config.shared.inputLLM : Config.shared.assistLLM
switch currentMode { switch mode {
case .input: case .input:
// Fn +
handleInputMode(text: text, config: config) handleInputMode(text: text, config: config)
case .assist: case .assist:
// Control AI
handleAssistMode(text: text, config: config) handleAssistMode(text: text, config: config)
} }

View File

@ -6,37 +6,33 @@ final class MenuBarController: NSObject {
private let statusItem: NSStatusItem private let statusItem: NSStatusItem
private let menu = NSMenu() private let menu = NSMenu()
private var languageMenuItems: [NSMenuItem] = [] private var languageMenuItems: [NSMenuItem] = []
private var modeMenuItems: [NSMenuItem] = []
// Input LLM (Fn - ) // LLM
private let inputLLMSubmenu = NSMenu() private let inputLLMSubmenu = NSMenu()
private let inputLLMEnabledItem: NSMenuItem private let inputLLMEnabledItem: NSMenuItem
// Assist LLM (Control - AI )
private let assistLLMSubmenu = NSMenu() private let assistLLMSubmenu = NSMenu()
private let assistLLMEnabledItem: NSMenuItem private let assistLLMEnabledItem: NSMenuItem
//
private let inputSettingsWindow: SettingsWindow private let inputSettingsWindow: SettingsWindow
private let assistSettingsWindow: SettingsWindow private let assistSettingsWindow: SettingsWindow
override init() { override init() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
// Input LLM
inputLLMEnabledItem = NSMenuItem( inputLLMEnabledItem = NSMenuItem(
title: "Enable", title: "Enable",
action: #selector(toggleInputLLM), action: #selector(toggleInputLLM),
keyEquivalent: "" keyEquivalent: ""
) )
// Assist LLM
assistLLMEnabledItem = NSMenuItem( assistLLMEnabledItem = NSMenuItem(
title: "Enable", title: "Enable",
action: #selector(toggleAssistLLM), action: #selector(toggleAssistLLM),
keyEquivalent: "" keyEquivalent: ""
) )
//
inputSettingsWindow = SettingsWindow(mode: .input) inputSettingsWindow = SettingsWindow(mode: .input)
assistSettingsWindow = SettingsWindow(mode: .assist) assistSettingsWindow = SettingsWindow(mode: .assist)
@ -88,7 +84,36 @@ final class MenuBarController: NSObject {
menu.addItem(.separator()) menu.addItem(.separator())
// ===== Input LLM (Fn - ) ===== // ===== =====
let modeMenu = NSMenu()
let inputModeItem = NSMenuItem(
title: "🎤 语音输入",
action: #selector(selectMode(_:)),
keyEquivalent: ""
)
inputModeItem.representedObject = TriggerMode.input
inputModeItem.state = Config.shared.triggerMode == .input ? .on : .off
modeMenuItems.append(inputModeItem)
modeMenu.addItem(inputModeItem)
let assistModeItem = NSMenuItem(
title: "💬 AI 助手",
action: #selector(selectMode(_:)),
keyEquivalent: ""
)
assistModeItem.representedObject = TriggerMode.assist
assistModeItem.state = Config.shared.triggerMode == .assist ? .on : .off
modeMenuItems.append(assistModeItem)
modeMenu.addItem(assistModeItem)
let modeSubmenuItem = NSMenuItem()
modeSubmenuItem.title = "Mode"
modeSubmenuItem.submenu = modeMenu
menu.addItem(modeSubmenuItem)
menu.addItem(.separator())
// ===== Input LLM =====
inputLLMSubmenu.addItem(inputLLMEnabledItem) inputLLMSubmenu.addItem(inputLLMEnabledItem)
inputLLMEnabledItem.target = self inputLLMEnabledItem.target = self
inputLLMEnabledItem.action = #selector(toggleInputLLM) inputLLMEnabledItem.action = #selector(toggleInputLLM)
@ -103,13 +128,13 @@ final class MenuBarController: NSObject {
inputLLMSubmenu.addItem(inputSettingsItem) inputLLMSubmenu.addItem(inputSettingsItem)
let inputSubmenuItem = NSMenuItem() let inputSubmenuItem = NSMenuItem()
inputSubmenuItem.title = "🎤 Input LLM (Fn)" inputSubmenuItem.title = "🎤 Input LLM"
inputSubmenuItem.submenu = inputLLMSubmenu inputSubmenuItem.submenu = inputLLMSubmenu
menu.addItem(inputSubmenuItem) menu.addItem(inputSubmenuItem)
menu.addItem(.separator()) menu.addItem(.separator())
// ===== Assist LLM (Control - AI ) ===== // ===== Assist LLM =====
assistLLMSubmenu.addItem(assistLLMEnabledItem) assistLLMSubmenu.addItem(assistLLMEnabledItem)
assistLLMEnabledItem.target = self assistLLMEnabledItem.target = self
assistLLMEnabledItem.action = #selector(toggleAssistLLM) assistLLMEnabledItem.action = #selector(toggleAssistLLM)
@ -124,7 +149,7 @@ final class MenuBarController: NSObject {
assistLLMSubmenu.addItem(assistSettingsItem) assistLLMSubmenu.addItem(assistSettingsItem)
let assistSubmenuItem = NSMenuItem() let assistSubmenuItem = NSMenuItem()
assistSubmenuItem.title = "💬 Assist LLM (Control)" assistSubmenuItem.title = "💬 Assist LLM"
assistSubmenuItem.submenu = assistLLMSubmenu assistSubmenuItem.submenu = assistLLMSubmenu
menu.addItem(assistSubmenuItem) menu.addItem(assistSubmenuItem)
@ -150,12 +175,18 @@ final class MenuBarController: NSObject {
assistLLMEnabledItem.state = Config.shared.assistLLM.enabled ? .on : .off assistLLMEnabledItem.state = Config.shared.assistLLM.enabled ? .on : .off
} }
private func updateModeMenuState() {
for item in modeMenuItems {
guard let mode = item.representedObject as? TriggerMode else { continue }
item.state = mode == Config.shared.triggerMode ? .on : .off
}
}
private func updateLanguageMenuState() { private func updateLanguageMenuState() {
for item in languageMenuItems { for item in languageMenuItems {
guard let lang = item.representedObject as? RecognitionLanguage else { continue } guard let lang = item.representedObject as? RecognitionLanguage else { continue }
item.state = lang == Config.shared.language ? .on : .off item.state = lang == Config.shared.language ? .on : .off
} }
//
if let first = menu.items.first { if let first = menu.items.first {
first.title = "Language: \(Config.shared.language.displayName)" first.title = "Language: \(Config.shared.language.displayName)"
} }
@ -168,6 +199,13 @@ final class MenuBarController: NSObject {
print("[Menu] Language switched to: \(lang.displayName)") print("[Menu] Language switched to: \(lang.displayName)")
} }
@objc private func selectMode(_ sender: NSMenuItem) {
guard let mode = sender.representedObject as? TriggerMode else { return }
Config.shared.triggerMode = mode
updateModeMenuState()
print("[Menu] Mode switched to: \(mode)")
}
@objc private func toggleInputLLM() { @objc private func toggleInputLLM() {
var config = Config.shared.inputLLM var config = Config.shared.inputLLM
config.enabled.toggle() config.enabled.toggle()
@ -186,15 +224,15 @@ final class MenuBarController: NSObject {
@objc private func openInputSettings() { @objc private func openInputSettings() {
inputSettingsWindow.makeKeyAndOrderFront(nil) inputSettingsWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApplication.shared.activate(ignoringOtherApps: true)
} }
@objc private func openAssistSettings() { @objc private func openAssistSettings() {
assistSettingsWindow.makeKeyAndOrderFront(nil) assistSettingsWindow.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true) NSApplication.shared.activate(ignoringOtherApps: true)
} }
@objc private func quit() { @objc private func quit() {
NSApp.terminate(nil) NSApplication.shared.terminate(nil)
} }
} }

View File

@ -1,23 +1,18 @@
import Cocoa import Cocoa
import CoreGraphics import CoreGraphics
/// Fn + Control AI /// Fn
final class KeyMonitor { final class KeyMonitor {
// Fn NX_SECONDARYFNMASK // Fn
private static let fnMask: UInt64 = 0x00800000 private static let fnMask: UInt64 = 0x00800000
// Control NX_CONTROLMASK
private static let controlMask: UInt64 = 0x00001000
private var eventTap: CFMachPort? private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource? private var runLoopSource: CFRunLoopSource?
private var isFnPressed = false private var isFnPressed = false
private var isControlPressed = false
var onFnDown: (() -> Void)? var onFnDown: (() -> Void)?
var onFnUp: (() -> Void)? var onFnUp: (() -> Void)?
var onControlDown: (() -> Void)?
var onControlUp: (() -> Void)?
func start() -> Bool { func start() -> Bool {
guard eventTap == nil else { return true } guard eventTap == nil else { return true }
@ -29,8 +24,6 @@ final class KeyMonitor {
} }
let eventMask = (1 << CGEventType.flagsChanged.rawValue) let eventMask = (1 << CGEventType.flagsChanged.rawValue)
| (1 << CGEventType.keyDown.rawValue)
| (1 << CGEventType.keyUp.rawValue)
let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged<CGEvent>? in
let monitor = Unmanaged<KeyMonitor>.fromOpaque(refcon!).takeUnretainedValue() let monitor = Unmanaged<KeyMonitor>.fromOpaque(refcon!).takeUnretainedValue()
@ -68,7 +61,7 @@ final class KeyMonitor {
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true) CGEvent.tapEnable(tap: tap, enable: true)
print("[KeyMonitor] ✅ Started (Fn + Control)") print("[KeyMonitor] ✅ Started")
return true return true
} }
@ -84,49 +77,30 @@ final class KeyMonitor {
} }
private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? { private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
let flags = event.flags.rawValue guard type == .flagsChanged else {
_ = event.getIntegerValueField(.keyboardEventKeycode)
let fnInFlags = (flags & Self.fnMask) != 0
let controlInFlags = (flags & Self.controlMask) != 0
switch type {
case .flagsChanged:
// Fn
if fnInFlags && !isFnPressed {
isFnPressed = true
DispatchQueue.main.async { [weak self] in
self?.onFnDown?()
}
return nil
}
if !fnInFlags && isFnPressed {
isFnPressed = false
DispatchQueue.main.async { [weak self] in
self?.onFnUp?()
}
return nil
}
// Control
if controlInFlags && !isControlPressed {
isControlPressed = true
DispatchQueue.main.async { [weak self] in
self?.onControlDown?()
}
return nil
}
if !controlInFlags && isControlPressed {
isControlPressed = false
DispatchQueue.main.async { [weak self] in
self?.onControlUp?()
}
return nil
}
return Unmanaged.passUnretained(event)
default:
return Unmanaged.passUnretained(event) return Unmanaged.passUnretained(event)
} }
let flags = event.flags.rawValue
let fnActive = (flags & Self.fnMask) != 0
if fnActive && !isFnPressed {
isFnPressed = true
print("[KeyMonitor] ⚡️ Fn DOWN")
DispatchQueue.main.async { [weak self] in
self?.onFnDown?()
}
return nil
}
if !fnActive && isFnPressed {
isFnPressed = false
print("[KeyMonitor] ⚡️ Fn UP")
DispatchQueue.main.async { [weak self] in
self?.onFnUp?()
}
return nil
}
return Unmanaged.passUnretained(event)
} }
} }

View File

@ -19,8 +19,13 @@ enum RecognitionLanguage: String, CaseIterable {
} }
enum LLMMode { enum LLMMode {
case input // Fn case input //
case assist // Control AI case assist // AI
}
enum TriggerMode: String {
case input = "input" //
case assist = "assist" // AI
} }
struct LLMConfig { struct LLMConfig {
@ -43,14 +48,15 @@ final class Config {
private enum Keys { private enum Keys {
static let language = "recognition_language" static let language = "recognition_language"
static let triggerMode = "trigger_mode"
// Fn // LLM
static let inputLLMEnabled = "input_llm_enabled" static let inputLLMEnabled = "input_llm_enabled"
static let inputLLMBaseURL = "input_llm_base_url" static let inputLLMBaseURL = "input_llm_base_url"
static let inputLLMAPIKey = "input_llm_api_key" static let inputLLMAPIKey = "input_llm_api_key"
static let inputLLMModel = "input_llm_model" static let inputLLMModel = "input_llm_model"
// Control AI // LLMAI
static let assistLLMEnabled = "assist_llm_enabled" static let assistLLMEnabled = "assist_llm_enabled"
static let assistLLMBaseURL = "assist_llm_base_url" static let assistLLMBaseURL = "assist_llm_base_url"
static let assistLLMAPIKey = "assist_llm_api_key" static let assistLLMAPIKey = "assist_llm_api_key"
@ -70,7 +76,22 @@ final class Config {
} }
} }
// MARK: - Fn // MARK: -
var triggerMode: TriggerMode {
get {
guard let raw = defaults.string(forKey: Keys.triggerMode),
let mode = TriggerMode(rawValue: raw) else {
return .input
}
return mode
}
set {
defaults.set(newValue.rawValue, forKey: Keys.triggerMode)
}
}
// MARK: -
var inputLLM: LLMConfig { var inputLLM: LLMConfig {
get { get {

Binary file not shown.