244 lines
7.9 KiB
Swift
244 lines
7.9 KiB
Swift
import Cocoa
|
||
|
||
/// 应用主控制器,协调 Fn 键(语音输入)和 Control 键(AI 助手)两种模式
|
||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||
|
||
private let keyMonitor = KeyMonitor()
|
||
private let speechRecognizer = SpeechRecognizer()
|
||
private let audioMonitor = AudioLevelMonitor()
|
||
private let recordingPanel = RecordingPanel()
|
||
private let menuBarController = MenuBarController()
|
||
private let llmRefiner = LLMRefiner()
|
||
|
||
private var isRecording = false
|
||
private var finalTranscription: String = ""
|
||
|
||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||
NSApp.setActivationPolicy(.accessory)
|
||
|
||
// 检查辅助功能权限
|
||
if !CGPreflightListenEventAccess() {
|
||
requestAccessibilityPermission()
|
||
} else {
|
||
startKeyMonitor()
|
||
}
|
||
|
||
print("[App] Ready")
|
||
}
|
||
|
||
// MARK: - Permissions
|
||
|
||
private func requestAccessibilityPermission() {
|
||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
|
||
|
||
let alert = NSAlert()
|
||
alert.messageText = "需要辅助功能权限"
|
||
alert.informativeText = """
|
||
Voice Input 需要「辅助功能」权限才能监听 Fn 键和 Control 键。
|
||
|
||
系统设置已自动打开,请:
|
||
1. 点击左下角的锁并输入密码
|
||
2. 在列表中找到并勾选「VoiceInput」
|
||
3. 返回本应用
|
||
|
||
点击「继续」后,应用将每 2 秒检查一次权限状态。
|
||
"""
|
||
alert.alertStyle = .informational
|
||
alert.addButton(withTitle: "继续")
|
||
alert.addButton(withTitle: "退出")
|
||
|
||
if alert.runModal() == .alertFirstButtonReturn {
|
||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
|
||
if CGPreflightListenEventAccess() {
|
||
timer.invalidate()
|
||
DispatchQueue.main.async {
|
||
self?.startKeyMonitor()
|
||
let successAlert = NSAlert()
|
||
successAlert.messageText = "权限已授权"
|
||
successAlert.informativeText = "辅助功能权限已开启,Voice Input 正在运行。"
|
||
successAlert.alertStyle = .informational
|
||
successAlert.runModal()
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
NSApp.terminate(nil)
|
||
}
|
||
}
|
||
|
||
private func startKeyMonitor() {
|
||
let started = keyMonitor.start()
|
||
if !started {
|
||
print("[App] Failed to start key monitor")
|
||
return
|
||
}
|
||
|
||
// Fn 键:根据当前模式执行不同功能
|
||
keyMonitor.onFnDown = { [weak self] in
|
||
self?.startRecording()
|
||
}
|
||
keyMonitor.onFnUp = { [weak self] in
|
||
self?.stopRecording()
|
||
}
|
||
keyMonitor.onFnDoubleClick = { [weak self] in
|
||
self?.toggleModeViaDoubleClick()
|
||
}
|
||
}
|
||
|
||
// MARK: - Double-Click Mode Switch
|
||
|
||
private func toggleModeViaDoubleClick() {
|
||
let newMode: TriggerMode = Config.shared.triggerMode == .input ? .assist : .input
|
||
Config.shared.triggerMode = newMode
|
||
menuBarController.refreshModeMenu()
|
||
print("[App] Double-click: mode switched to \(newMode)")
|
||
}
|
||
|
||
// MARK: - Recording
|
||
|
||
private func startRecording() {
|
||
guard !isRecording else { return }
|
||
isRecording = true
|
||
finalTranscription = ""
|
||
|
||
recordingPanel.showAnimated()
|
||
|
||
// 根据模式显示不同提示
|
||
let promptText = Config.shared.triggerMode == .input ? "Listening..." : "Ask AI..."
|
||
recordingPanel.updateText(promptText)
|
||
|
||
let language = Config.shared.language.rawValue
|
||
|
||
Task { @MainActor in
|
||
let audioOK = audioMonitor.start()
|
||
if audioOK {
|
||
audioMonitor.onLevelUpdate = { [weak self] level in
|
||
self?.recordingPanel.waveformView.updateLevel(level)
|
||
}
|
||
}
|
||
|
||
let speechOK = await speechRecognizer.start(language: language)
|
||
if !speechOK {
|
||
recordingPanel.updateText("Recognition error")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||
self?.cancelRecording()
|
||
}
|
||
return
|
||
}
|
||
|
||
speechRecognizer.onResult = { [weak self] text in
|
||
guard let self = self, self.isRecording else { return }
|
||
self.finalTranscription = text
|
||
self.recordingPanel.updateText(text)
|
||
}
|
||
|
||
speechRecognizer.onError = { error in
|
||
let nsError = error as NSError
|
||
if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 {
|
||
return
|
||
}
|
||
print("[App] Speech error: \(error)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func stopRecording() {
|
||
guard isRecording else { return }
|
||
isRecording = false
|
||
|
||
audioMonitor.stop()
|
||
_ = speechRecognizer.stop()
|
||
|
||
let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !text.isEmpty else {
|
||
recordingPanel.hideAnimated()
|
||
finalTranscription = ""
|
||
return
|
||
}
|
||
|
||
// 根据 triggerMode 选择配置和逻辑
|
||
let mode = Config.shared.triggerMode
|
||
let config = mode == .input ? Config.shared.inputLLM : Config.shared.assistLLM
|
||
|
||
switch mode {
|
||
case .input:
|
||
handleInputMode(text: text, config: config)
|
||
case .assist:
|
||
handleAssistMode(text: text, config: config)
|
||
}
|
||
|
||
finalTranscription = ""
|
||
}
|
||
|
||
// MARK: - Input Mode (Fn)
|
||
|
||
private func handleInputMode(text: String, config: LLMConfig) {
|
||
guard config.enabled && config.isConfigured else {
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(text)
|
||
}
|
||
return
|
||
}
|
||
|
||
recordingPanel.showRefining()
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let result = try await llmRefiner.refine(text: text, config: config)
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(result.text)
|
||
}
|
||
} catch {
|
||
print("[App] LLM refine error: \(error)")
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(text)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Assist Mode (Control)
|
||
|
||
private func handleAssistMode(text: String, config: LLMConfig) {
|
||
guard config.enabled && config.isConfigured else {
|
||
recordingPanel.updateText("AI 助手未配置")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
|
||
self?.recordingPanel.hideAnimated()
|
||
}
|
||
return
|
||
}
|
||
|
||
recordingPanel.showThinking()
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let result = try await llmRefiner.chat(text: text, config: config)
|
||
recordingPanel.hideAnimated {
|
||
TextInjector.inject(result.text)
|
||
}
|
||
} catch {
|
||
print("[App] LLM chat error: \(error)")
|
||
recordingPanel.updateText("请求失败,请检查配置")
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
|
||
self?.recordingPanel.hideAnimated()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func cancelRecording() {
|
||
audioMonitor.stop()
|
||
speechRecognizer.cancel()
|
||
recordingPanel.hideAnimated()
|
||
isRecording = false
|
||
finalTranscription = ""
|
||
}
|
||
|
||
func applicationWillTerminate(_ notification: Notification) {
|
||
keyMonitor.stop()
|
||
audioMonitor.stop()
|
||
speechRecognizer.cancel()
|
||
recordingPanel.close()
|
||
}
|
||
}
|