feat: 双模式 LLM - Fn 语音输入 + Control AI 助手,独立配置
This commit is contained in:
parent
0c1482c983
commit
67b3024bda
@ -1,6 +1,6 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
|
|
||||||
/// 应用主控制器,协调按键监听、语音识别、LLM 纠错和文本注入
|
/// 应用主控制器,协调 Fn 键(语音输入)和 Control 键(AI 助手)两种模式
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
|
||||||
private let keyMonitor = KeyMonitor()
|
private let keyMonitor = KeyMonitor()
|
||||||
@ -12,6 +12,7 @@ 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)
|
||||||
@ -29,14 +30,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
// MARK: - Permissions
|
// MARK: - Permissions
|
||||||
|
|
||||||
private func requestAccessibilityPermission() {
|
private func requestAccessibilityPermission() {
|
||||||
// 自动打开系统设置
|
|
||||||
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
|
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
|
||||||
|
|
||||||
// 显示提示
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.messageText = "需要辅助功能权限"
|
alert.messageText = "需要辅助功能权限"
|
||||||
alert.informativeText = """
|
alert.informativeText = """
|
||||||
Voice Input 需要「辅助功能」权限才能监听 Fn 键。
|
Voice Input 需要「辅助功能」权限才能监听 Fn 键和 Control 键。
|
||||||
|
|
||||||
系统设置已自动打开,请:
|
系统设置已自动打开,请:
|
||||||
1. 点击左下角的锁并输入密码
|
1. 点击左下角的锁并输入密码
|
||||||
@ -50,13 +49,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
alert.addButton(withTitle: "退出")
|
alert.addButton(withTitle: "退出")
|
||||||
|
|
||||||
if alert.runModal() == .alertFirstButtonReturn {
|
if alert.runModal() == .alertFirstButtonReturn {
|
||||||
// 轮询检查权限
|
|
||||||
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
|
Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] timer in
|
||||||
if CGPreflightListenEventAccess() {
|
if CGPreflightListenEventAccess() {
|
||||||
timer.invalidate()
|
timer.invalidate()
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.startKeyMonitor()
|
self?.startKeyMonitor()
|
||||||
// 显示成功提示
|
|
||||||
let successAlert = NSAlert()
|
let successAlert = NSAlert()
|
||||||
successAlert.messageText = "权限已授权"
|
successAlert.messageText = "权限已授权"
|
||||||
successAlert.informativeText = "辅助功能权限已开启,Voice Input 正在运行。"
|
successAlert.informativeText = "辅助功能权限已开启,Voice Input 正在运行。"
|
||||||
@ -77,13 +74,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@ -94,12 +101,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
finalTranscription = ""
|
finalTranscription = ""
|
||||||
|
|
||||||
recordingPanel.showAnimated()
|
recordingPanel.showAnimated()
|
||||||
recordingPanel.updateText("Listening...")
|
|
||||||
|
// 根据模式显示不同提示
|
||||||
|
let promptText = currentMode == .input ? "Listening..." : "Ask AI..."
|
||||||
|
recordingPanel.updateText(promptText)
|
||||||
|
|
||||||
let language = Config.shared.language.rawValue
|
let language = Config.shared.language.rawValue
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// 启动音频电平监测
|
|
||||||
let audioOK = audioMonitor.start()
|
let audioOK = audioMonitor.start()
|
||||||
if audioOK {
|
if audioOK {
|
||||||
audioMonitor.onLevelUpdate = { [weak self] level in
|
audioMonitor.onLevelUpdate = { [weak self] level in
|
||||||
@ -107,7 +116,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动语音识别
|
|
||||||
let speechOK = await speechRecognizer.start(language: language)
|
let speechOK = await speechRecognizer.start(language: language)
|
||||||
if !speechOK {
|
if !speechOK {
|
||||||
recordingPanel.updateText("Recognition error")
|
recordingPanel.updateText("Recognition error")
|
||||||
@ -125,18 +133,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
|
|
||||||
speechRecognizer.onError = { error in
|
speechRecognizer.onError = { error in
|
||||||
let nsError = error as NSError
|
let nsError = error as NSError
|
||||||
// kAFAssistantErrorDomain code 1110 = 未检测到语音,属正常情况,不报错
|
|
||||||
if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 {
|
if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
print("[App] Speech error: \(error)")
|
print("[App] Speech error: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
speechRecognizer.onStateChange = { [weak self] isActive in
|
|
||||||
if !isActive, self?.isRecording == true {
|
|
||||||
// 识别意外停止
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,41 +145,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
guard isRecording else { return }
|
guard isRecording else { return }
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
|
||||||
// 停止音频和识别
|
|
||||||
audioMonitor.stop()
|
audioMonitor.stop()
|
||||||
_ = speechRecognizer.stop()
|
_ = speechRecognizer.stop()
|
||||||
|
|
||||||
let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines)
|
let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else {
|
||||||
// 如果 LLM 启用且已配置
|
|
||||||
if Config.shared.llmEnabled && Config.shared.isLLMConfigured && !text.isEmpty {
|
|
||||||
recordingPanel.showRefining()
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
do {
|
|
||||||
let result = try await llmRefiner.refine(text: text, config: Config.shared)
|
|
||||||
recordingPanel.hideAnimated {
|
|
||||||
TextInjector.inject(result.refinedText)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
print("[App] LLM error: \(error)")
|
|
||||||
// LLM 失败,回退到原始文本
|
|
||||||
recordingPanel.hideAnimated {
|
|
||||||
TextInjector.inject(text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if !text.isEmpty {
|
|
||||||
recordingPanel.hideAnimated {
|
|
||||||
TextInjector.inject(text)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
recordingPanel.hideAnimated()
|
recordingPanel.hideAnimated()
|
||||||
|
finalTranscription = ""
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let config = Config.shared.getLLMConfig(for: currentMode)
|
||||||
|
|
||||||
|
switch currentMode {
|
||||||
|
case .input:
|
||||||
|
// Fn 键模式:语音输入 + 纠错
|
||||||
|
handleInputMode(text: text, config: config)
|
||||||
|
case .assist:
|
||||||
|
// Control 键模式:AI 助手对话
|
||||||
|
handleAssistMode(text: text, config: config)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalTranscription = ""
|
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() {
|
private func cancelRecording() {
|
||||||
audioMonitor.stop()
|
audioMonitor.stop()
|
||||||
speechRecognizer.cancel()
|
speechRecognizer.cancel()
|
||||||
|
|||||||
@ -7,20 +7,38 @@ final class MenuBarController: NSObject {
|
|||||||
private let menu = NSMenu()
|
private let menu = NSMenu()
|
||||||
private var languageMenuItems: [NSMenuItem] = []
|
private var languageMenuItems: [NSMenuItem] = []
|
||||||
|
|
||||||
// 子菜单引用
|
// Input LLM (Fn 键 - 语音输入纠错)
|
||||||
private let llmSubmenu = NSMenu()
|
private let inputLLMSubmenu = NSMenu()
|
||||||
private let llmEnabledItem: NSMenuItem
|
private let inputLLMEnabledItem: NSMenuItem
|
||||||
private let settingsWindow = SettingsWindow()
|
|
||||||
|
// Assist LLM (Control 键 - AI 助手)
|
||||||
|
private let assistLLMSubmenu = NSMenu()
|
||||||
|
private let assistLLMEnabledItem: NSMenuItem
|
||||||
|
|
||||||
|
// 设置窗口
|
||||||
|
private let inputSettingsWindow: SettingsWindow
|
||||||
|
private let assistSettingsWindow: SettingsWindow
|
||||||
|
|
||||||
override init() {
|
override init() {
|
||||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|
||||||
// LLM 启用/禁用
|
// Input LLM 启用项
|
||||||
llmEnabledItem = NSMenuItem(
|
inputLLMEnabledItem = NSMenuItem(
|
||||||
title: "Enable LLM Refinement",
|
title: "Enable",
|
||||||
action: #selector(toggleLLM),
|
action: #selector(toggleInputLLM),
|
||||||
keyEquivalent: ""
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Assist LLM 启用项
|
||||||
|
assistLLMEnabledItem = NSMenuItem(
|
||||||
|
title: "Enable",
|
||||||
|
action: #selector(toggleAssistLLM),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建两个设置窗口
|
||||||
|
inputSettingsWindow = SettingsWindow(mode: .input)
|
||||||
|
assistSettingsWindow = SettingsWindow(mode: .assist)
|
||||||
|
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
@ -70,24 +88,45 @@ final class MenuBarController: NSObject {
|
|||||||
|
|
||||||
menu.addItem(.separator())
|
menu.addItem(.separator())
|
||||||
|
|
||||||
// LLM 子菜单
|
// ===== Input LLM (Fn 键 - 语音输入纠错) =====
|
||||||
llmSubmenu.addItem(llmEnabledItem)
|
inputLLMSubmenu.addItem(inputLLMEnabledItem)
|
||||||
llmEnabledItem.target = self
|
inputLLMEnabledItem.target = self
|
||||||
llmEnabledItem.action = #selector(toggleLLM)
|
inputLLMEnabledItem.action = #selector(toggleInputLLM)
|
||||||
updateLLMMenuState()
|
updateInputLLMMenuState()
|
||||||
|
|
||||||
let settingsItem = NSMenuItem(
|
let inputSettingsItem = NSMenuItem(
|
||||||
title: "LLM Settings...",
|
title: "Settings...",
|
||||||
action: #selector(openSettings),
|
action: #selector(openInputSettings),
|
||||||
keyEquivalent: ","
|
keyEquivalent: ""
|
||||||
)
|
)
|
||||||
settingsItem.target = self
|
inputSettingsItem.target = self
|
||||||
llmSubmenu.addItem(settingsItem)
|
inputLLMSubmenu.addItem(inputSettingsItem)
|
||||||
|
|
||||||
let llmSubmenuItem = NSMenuItem()
|
let inputSubmenuItem = NSMenuItem()
|
||||||
llmSubmenuItem.title = "LLM Refinement"
|
inputSubmenuItem.title = "🎤 Input LLM (Fn)"
|
||||||
llmSubmenuItem.submenu = llmSubmenu
|
inputSubmenuItem.submenu = inputLLMSubmenu
|
||||||
menu.addItem(llmSubmenuItem)
|
menu.addItem(inputSubmenuItem)
|
||||||
|
|
||||||
|
menu.addItem(.separator())
|
||||||
|
|
||||||
|
// ===== Assist LLM (Control 键 - AI 助手) =====
|
||||||
|
assistLLMSubmenu.addItem(assistLLMEnabledItem)
|
||||||
|
assistLLMEnabledItem.target = self
|
||||||
|
assistLLMEnabledItem.action = #selector(toggleAssistLLM)
|
||||||
|
updateAssistLLMMenuState()
|
||||||
|
|
||||||
|
let assistSettingsItem = NSMenuItem(
|
||||||
|
title: "Settings...",
|
||||||
|
action: #selector(openAssistSettings),
|
||||||
|
keyEquivalent: ""
|
||||||
|
)
|
||||||
|
assistSettingsItem.target = self
|
||||||
|
assistLLMSubmenu.addItem(assistSettingsItem)
|
||||||
|
|
||||||
|
let assistSubmenuItem = NSMenuItem()
|
||||||
|
assistSubmenuItem.title = "💬 Assist LLM (Control)"
|
||||||
|
assistSubmenuItem.submenu = assistLLMSubmenu
|
||||||
|
menu.addItem(assistSubmenuItem)
|
||||||
|
|
||||||
menu.addItem(.separator())
|
menu.addItem(.separator())
|
||||||
|
|
||||||
@ -103,8 +142,12 @@ final class MenuBarController: NSObject {
|
|||||||
statusItem.menu = menu
|
statusItem.menu = menu
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLLMMenuState() {
|
private func updateInputLLMMenuState() {
|
||||||
llmEnabledItem.state = Config.shared.llmEnabled ? .on : .off
|
inputLLMEnabledItem.state = Config.shared.inputLLM.enabled ? .on : .off
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateAssistLLMMenuState() {
|
||||||
|
assistLLMEnabledItem.state = Config.shared.assistLLM.enabled ? .on : .off
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateLanguageMenuState() {
|
private func updateLanguageMenuState() {
|
||||||
@ -125,14 +168,29 @@ final class MenuBarController: NSObject {
|
|||||||
print("[Menu] Language switched to: \(lang.displayName)")
|
print("[Menu] Language switched to: \(lang.displayName)")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func toggleLLM() {
|
@objc private func toggleInputLLM() {
|
||||||
Config.shared.llmEnabled.toggle()
|
var config = Config.shared.inputLLM
|
||||||
updateLLMMenuState()
|
config.enabled.toggle()
|
||||||
print("[Menu] LLM Refinement: \(Config.shared.llmEnabled ? "ON" : "OFF")")
|
Config.shared.inputLLM = config
|
||||||
|
updateInputLLMMenuState()
|
||||||
|
print("[Menu] Input LLM: \(config.enabled ? "ON" : "OFF")")
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func openSettings() {
|
@objc private func toggleAssistLLM() {
|
||||||
settingsWindow.makeKeyAndOrderFront(nil)
|
var config = Config.shared.assistLLM
|
||||||
|
config.enabled.toggle()
|
||||||
|
Config.shared.assistLLM = config
|
||||||
|
updateAssistLLMMenuState()
|
||||||
|
print("[Menu] Assist LLM: \(config.enabled ? "ON" : "OFF")")
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openInputSettings() {
|
||||||
|
inputSettingsWindow.makeKeyAndOrderFront(nil)
|
||||||
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func openAssistSettings() {
|
||||||
|
assistSettingsWindow.makeKeyAndOrderFront(nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,23 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
|
|
||||||
/// 全局 Fn / Globe 键监听器
|
/// 全局热键监听器:Fn 键(语音输入)+ Control 键(AI 助手)
|
||||||
final class KeyMonitor {
|
final class KeyMonitor {
|
||||||
|
|
||||||
// Fn 键标志:CGEventFlags.maskSecondaryFn = 0x00800000
|
// Fn 键:NX_SECONDARYFNMASK
|
||||||
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 }
|
||||||
@ -32,7 +37,6 @@ final class KeyMonitor {
|
|||||||
return monitor.handleEvent(proxy: proxy, type: type, event: event)
|
return monitor.handleEvent(proxy: proxy, type: type, event: event)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试 HID 级 tap(能捕获被系统拦截的 Fn 键)
|
|
||||||
var tap = CGEvent.tapCreate(
|
var tap = CGEvent.tapCreate(
|
||||||
tap: .cghidEventTap,
|
tap: .cghidEventTap,
|
||||||
place: .headInsertEventTap,
|
place: .headInsertEventTap,
|
||||||
@ -43,7 +47,6 @@ final class KeyMonitor {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if tap == nil {
|
if tap == nil {
|
||||||
// 回退到会话级 tap
|
|
||||||
print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap")
|
print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap")
|
||||||
tap = CGEvent.tapCreate(
|
tap = CGEvent.tapCreate(
|
||||||
tap: .cgSessionEventTap,
|
tap: .cgSessionEventTap,
|
||||||
@ -65,7 +68,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")
|
print("[KeyMonitor] ✅ Started (Fn + Control)")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,24 +85,43 @@ 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
|
let flags = event.flags.rawValue
|
||||||
|
_ = event.getIntegerValueField(.keyboardEventKeycode)
|
||||||
|
|
||||||
let fnInFlags = (flags & Self.fnMask) != 0
|
let fnInFlags = (flags & Self.fnMask) != 0
|
||||||
|
let controlInFlags = (flags & Self.controlMask) != 0
|
||||||
|
|
||||||
switch type {
|
switch type {
|
||||||
case .flagsChanged:
|
case .flagsChanged:
|
||||||
|
// Fn 键检测
|
||||||
if fnInFlags && !isFnPressed {
|
if fnInFlags && !isFnPressed {
|
||||||
print("[KeyMonitor] ⚡️ Fn DOWN (flags=0x\(String(flags, radix: 16)))")
|
isFnPressed = true
|
||||||
return handleFnDown()
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onFnDown?()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if !fnInFlags && isFnPressed {
|
if !fnInFlags && isFnPressed {
|
||||||
print("[KeyMonitor] ⚡️ Fn UP (flags=0x\(String(flags, radix: 16)))")
|
isFnPressed = false
|
||||||
return handleFnUp()
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onFnUp?()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return Unmanaged.passUnretained(event)
|
|
||||||
|
// Control 键检测
|
||||||
case .keyDown, .keyUp:
|
if controlInFlags && !isControlPressed {
|
||||||
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
|
isControlPressed = true
|
||||||
if keyCode == 63 && fnInFlags {
|
DispatchQueue.main.async { [weak self] in
|
||||||
return handleFnDown()
|
self?.onControlDown?()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !controlInFlags && isControlPressed {
|
||||||
|
isControlPressed = false
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.onControlUp?()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return Unmanaged.passUnretained(event)
|
return Unmanaged.passUnretained(event)
|
||||||
|
|
||||||
@ -107,24 +129,4 @@ final class KeyMonitor {
|
|||||||
return Unmanaged.passUnretained(event)
|
return Unmanaged.passUnretained(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFnDown() -> Unmanaged<CGEvent>? {
|
|
||||||
if !isFnPressed {
|
|
||||||
isFnPressed = true
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.onFnDown?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleFnUp() -> Unmanaged<CGEvent>? {
|
|
||||||
if isFnPressed {
|
|
||||||
isFnPressed = false
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.onFnUp?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,36 +1,23 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// 通过 OpenAI 兼容 API 对转录文本进行纠错优化
|
/// 通过 OpenAI 兼容 API 进行 LLM 处理
|
||||||
final class LLMRefiner {
|
final class LLMRefiner {
|
||||||
|
|
||||||
private let session: URLSession = {
|
private let session: URLSession = {
|
||||||
let config = URLSessionConfiguration.default
|
let config = URLSessionConfiguration.default
|
||||||
config.timeoutIntervalForRequest = 15
|
config.timeoutIntervalForRequest = 30
|
||||||
config.timeoutIntervalForResource = 20
|
config.timeoutIntervalForResource = 60
|
||||||
return URLSession(configuration: config)
|
return URLSession(configuration: config)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// 纠错结果
|
|
||||||
struct RefinementResult {
|
struct RefinementResult {
|
||||||
let refinedText: String
|
let text: String
|
||||||
let wasChanged: Bool
|
let wasChanged: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对转录文本进行纠错
|
// MARK: - Fn 键模式:语音输入纠错
|
||||||
/// - Parameters:
|
|
||||||
/// - text: 原始转录文本
|
|
||||||
/// - config: LLM 配置
|
|
||||||
/// - Returns: 纠错结果
|
|
||||||
func refine(text: String, config: Config) async throws -> RefinementResult {
|
|
||||||
let baseURL = config.llmBaseURL
|
|
||||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
let url = URL(string: "\(baseURL)/v1/chat/completions")!
|
|
||||||
|
|
||||||
var urlRequest = URLRequest(url: url)
|
|
||||||
urlRequest.httpMethod = "POST"
|
|
||||||
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
urlRequest.setValue("Bearer \(config.llmAPIKey)", forHTTPHeaderField: "Authorization")
|
|
||||||
|
|
||||||
|
func refine(text: String, config: LLMConfig) async throws -> RefinementResult {
|
||||||
let systemPrompt = """
|
let systemPrompt = """
|
||||||
你是一个语音识别后处理工具。你唯一的任务是修正转录文本中的明显识别错误。规则:
|
你是一个语音识别后处理工具。你唯一的任务是修正转录文本中的明显识别错误。规则:
|
||||||
1. 修正中文同音字/词错误。语音识别经常把技术词汇识别为同音的日常词汇,例如:
|
1. 修正中文同音字/词错误。语音识别经常把技术词汇识别为同音的日常词汇,例如:
|
||||||
@ -44,6 +31,46 @@ final class LLMRefiner {
|
|||||||
7. 保留所有标点符号、空格、大小写和格式。
|
7. 保留所有标点符号、空格、大小写和格式。
|
||||||
8. 只返回修正后的文本,不要添加任何解释、前缀、后缀或 Markdown 标记。
|
8. 只返回修正后的文本,不要添加任何解释、前缀、后缀或 Markdown 标记。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
return try await callLLM(
|
||||||
|
text: text,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
config: config
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Control 键模式:AI 助手对话
|
||||||
|
|
||||||
|
func chat(text: String, config: LLMConfig) async throws -> RefinementResult {
|
||||||
|
let systemPrompt = """
|
||||||
|
你是一个 AI 助手,用户通过语音输入与你交流。用户的输入来自语音识别,可能存在同音字或转录错误,请结合上下文理解。
|
||||||
|
|
||||||
|
你的任务:
|
||||||
|
1. 回答用户的问题、提供信息或执行对话
|
||||||
|
2. 保持回答简洁自然,适合口语化场景
|
||||||
|
3. 如果用户输入模糊,尝试猜测意图并给出最可能的回答
|
||||||
|
4. 使用用户正在使用的语言,或用户明确要求的语言进行回复
|
||||||
|
5. 不要包含任何解释性文字、前缀、后缀或 Markdown 标记
|
||||||
|
6. 你的输出将直接进入用户的输入框,所以只返回最终回答内容
|
||||||
|
"""
|
||||||
|
|
||||||
|
return try await callLLM(
|
||||||
|
text: text,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
config: config
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 通用调用
|
||||||
|
|
||||||
|
private func callLLM(text: String, systemPrompt: String, config: LLMConfig) async throws -> RefinementResult {
|
||||||
|
let baseURL = config.baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
let url = URL(string: "\(baseURL)/v1/chat/completions")!
|
||||||
|
|
||||||
|
var urlRequest = URLRequest(url: url)
|
||||||
|
urlRequest.httpMethod = "POST"
|
||||||
|
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
urlRequest.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
|
||||||
let messages: [[String: String]] = [
|
let messages: [[String: String]] = [
|
||||||
["role": "system", "content": systemPrompt],
|
["role": "system", "content": systemPrompt],
|
||||||
@ -51,7 +78,7 @@ final class LLMRefiner {
|
|||||||
]
|
]
|
||||||
|
|
||||||
let body: [String: Any] = [
|
let body: [String: Any] = [
|
||||||
"model": config.llmModel,
|
"model": config.model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": 4096,
|
"max_tokens": 4096,
|
||||||
"thinking": ["type": "disabled"]
|
"thinking": ["type": "disabled"]
|
||||||
@ -76,22 +103,25 @@ final class LLMRefiner {
|
|||||||
let choices = json["choices"] as? [[String: Any]],
|
let choices = json["choices"] as? [[String: Any]],
|
||||||
let firstChoice = choices.first,
|
let firstChoice = choices.first,
|
||||||
let message = firstChoice["message"] as? [String: Any],
|
let message = firstChoice["message"] as? [String: Any],
|
||||||
let refined = message["content"] as? String else {
|
let result = message["content"] as? String else {
|
||||||
throw NSError(domain: "LLMRefiner", code: -2,
|
throw NSError(domain: "LLMRefiner", code: -2,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"])
|
userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"])
|
||||||
}
|
}
|
||||||
|
|
||||||
let trimmed = refined.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let wasChanged = trimmed != text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let wasChanged = trimmed != text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
print("[LLM] Refinement \(wasChanged ? "changed" : "unchanged"): \"\(trimmed.prefix(80))...\"")
|
return RefinementResult(text: trimmed, wasChanged: wasChanged)
|
||||||
return RefinementResult(refinedText: trimmed, wasChanged: wasChanged)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试连接
|
/// 测试连接
|
||||||
func testConnection(config: Config) async throws -> String {
|
func testConnection(config: LLMConfig) async throws -> String {
|
||||||
let testText = "测试连接"
|
let testText = "测试连接"
|
||||||
let result = try await refine(text: testText, config: config)
|
let result = try await callLLM(
|
||||||
return result.refinedText
|
text: testText,
|
||||||
|
systemPrompt: "You are a helpful assistant. Reply with 'OK' only.",
|
||||||
|
config: config
|
||||||
|
)
|
||||||
|
return result.text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -231,6 +231,33 @@ final class RecordingPanel: NSPanel {
|
|||||||
waveformView.updateLevel(0.3)
|
waveformView.updateLevel(0.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 显示 AI 助手思考状态
|
||||||
|
func showThinking() {
|
||||||
|
textLabel.stringValue = "Thinking..."
|
||||||
|
let thinkingTextWidth: CGFloat = 120
|
||||||
|
let targetWidth: CGFloat = thinkingTextWidth + waveformSize.width + 44
|
||||||
|
|
||||||
|
NSAnimationContext.runAnimationGroup { ctx in
|
||||||
|
ctx.duration = 0.2
|
||||||
|
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
||||||
|
ctx.allowsImplicitAnimation = true
|
||||||
|
|
||||||
|
textWidthConstraint?.animator().constant = thinkingTextWidth
|
||||||
|
|
||||||
|
let frame = currentScreenFrame()
|
||||||
|
let y = bottomY(for: frame)
|
||||||
|
let newFrame = NSRect(
|
||||||
|
x: (frame.width - targetWidth) / 2,
|
||||||
|
y: y,
|
||||||
|
width: targetWidth,
|
||||||
|
height: panelHeight
|
||||||
|
)
|
||||||
|
animator().setFrame(newFrame, display: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
waveformView.updateLevel(0.2)
|
||||||
|
}
|
||||||
|
|
||||||
// 重写:允许在非 active 状态响应
|
// 重写:允许在非 active 状态响应
|
||||||
override var canBecomeKey: Bool { true }
|
override var canBecomeKey: Bool { true }
|
||||||
override var canBecomeMain: Bool { false }
|
override var canBecomeMain: Bool { false }
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import Cocoa
|
import Cocoa
|
||||||
|
|
||||||
/// LLM 设置窗口
|
/// LLM 设置窗口(支持双模式)
|
||||||
final class SettingsWindow: NSWindow {
|
final class SettingsWindow: NSWindow {
|
||||||
|
|
||||||
|
private let mode: LLMMode
|
||||||
private let baseURLField: NSTextField
|
private let baseURLField: NSTextField
|
||||||
private let apiKeyField: NSSecureTextField
|
private let apiKeyField: NSSecureTextField
|
||||||
private let modelField: NSTextField
|
private let modelField: NSTextField
|
||||||
@ -10,8 +11,9 @@ final class SettingsWindow: NSWindow {
|
|||||||
private let saveButton: NSButton
|
private let saveButton: NSButton
|
||||||
private let statusLabel: NSTextField
|
private let statusLabel: NSTextField
|
||||||
|
|
||||||
init() {
|
init(mode: LLMMode) {
|
||||||
// 表单字段
|
self.mode = mode
|
||||||
|
|
||||||
baseURLField = NSTextField(frame: .zero)
|
baseURLField = NSTextField(frame: .zero)
|
||||||
apiKeyField = NSSecureTextField(frame: .zero)
|
apiKeyField = NSSecureTextField(frame: .zero)
|
||||||
modelField = NSTextField(frame: .zero)
|
modelField = NSTextField(frame: .zero)
|
||||||
@ -33,7 +35,7 @@ final class SettingsWindow: NSWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func configureWindow() {
|
private func configureWindow() {
|
||||||
title = "LLM Settings"
|
title = mode == .input ? "Input LLM Settings (Fn)" : "Assist LLM Settings (Control)"
|
||||||
isReleasedWhenClosed = false
|
isReleasedWhenClosed = false
|
||||||
center()
|
center()
|
||||||
}
|
}
|
||||||
@ -41,7 +43,6 @@ final class SettingsWindow: NSWindow {
|
|||||||
private func buildUI() {
|
private func buildUI() {
|
||||||
guard let content = contentView else { return }
|
guard let content = contentView else { return }
|
||||||
|
|
||||||
// 标签
|
|
||||||
func makeLabel(_ text: String) -> NSTextField {
|
func makeLabel(_ text: String) -> NSTextField {
|
||||||
let label = NSTextField(labelWithString: text)
|
let label = NSTextField(labelWithString: text)
|
||||||
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
label.font = .systemFont(ofSize: 11, weight: .semibold)
|
||||||
@ -51,20 +52,10 @@ final class SettingsWindow: NSWindow {
|
|||||||
return label
|
return label
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeField(_ placeholder: String, isSecure: Bool = false) -> NSTextField {
|
|
||||||
let field = isSecure ? NSSecureTextField(frame: .zero) : NSTextField(frame: .zero)
|
|
||||||
field.placeholderString = placeholder
|
|
||||||
field.font = .systemFont(ofSize: 13)
|
|
||||||
field.bezelStyle = .roundedBezel
|
|
||||||
field.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
return field
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseURLLabel = makeLabel("API Base URL:")
|
let baseURLLabel = makeLabel("API Base URL:")
|
||||||
let apiKeyLabel = makeLabel("API Key:")
|
let apiKeyLabel = makeLabel("API Key:")
|
||||||
let modelLabel = makeLabel("Model:")
|
let modelLabel = makeLabel("Model:")
|
||||||
|
|
||||||
// 使用实例字段
|
|
||||||
baseURLField.placeholderString = "https://api.openai.com"
|
baseURLField.placeholderString = "https://api.openai.com"
|
||||||
baseURLField.font = .systemFont(ofSize: 13)
|
baseURLField.font = .systemFont(ofSize: 13)
|
||||||
baseURLField.bezelStyle = .roundedBezel
|
baseURLField.bezelStyle = .roundedBezel
|
||||||
@ -75,7 +66,7 @@ final class SettingsWindow: NSWindow {
|
|||||||
apiKeyField.bezelStyle = .roundedBezel
|
apiKeyField.bezelStyle = .roundedBezel
|
||||||
apiKeyField.translatesAutoresizingMaskIntoConstraints = false
|
apiKeyField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
modelField.placeholderString = "gpt-3.5-turbo"
|
modelField.placeholderString = mode == .input ? "deepseek-v4-flash" : "gpt-4"
|
||||||
modelField.font = .systemFont(ofSize: 13)
|
modelField.font = .systemFont(ofSize: 13)
|
||||||
modelField.bezelStyle = .roundedBezel
|
modelField.bezelStyle = .roundedBezel
|
||||||
modelField.translatesAutoresizingMaskIntoConstraints = false
|
modelField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
@ -97,11 +88,10 @@ final class SettingsWindow: NSWindow {
|
|||||||
statusLabel.maximumNumberOfLines = 2
|
statusLabel.maximumNumberOfLines = 2
|
||||||
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
statusLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
// 布局
|
|
||||||
let grid = NSGridView(views: [
|
let grid = NSGridView(views: [
|
||||||
[baseURLLabel, baseURLField],
|
[baseURLLabel, baseURLField],
|
||||||
[apiKeyLabel, apiKeyField],
|
[apiKeyLabel, apiKeyField],
|
||||||
[modelLabel, modelField]
|
[modelLabel, modelField]
|
||||||
])
|
])
|
||||||
grid.translatesAutoresizingMaskIntoConstraints = false
|
grid.translatesAutoresizingMaskIntoConstraints = false
|
||||||
grid.column(at: 0).xPlacement = .trailing
|
grid.column(at: 0).xPlacement = .trailing
|
||||||
@ -135,20 +125,13 @@ final class SettingsWindow: NSWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func loadCurrentConfig() {
|
private func loadCurrentConfig() {
|
||||||
baseURLField.stringValue = Config.shared.llmBaseURL
|
let config = Config.shared.getLLMConfig(for: mode)
|
||||||
apiKeyField.stringValue = Config.shared.llmAPIKey
|
baseURLField.stringValue = config.baseURL
|
||||||
modelField.stringValue = Config.shared.llmModel
|
apiKeyField.stringValue = config.apiKey
|
||||||
|
modelField.stringValue = config.model
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func testConnection() {
|
@objc private func testConnection() {
|
||||||
// 使用输入框当前值进行测试
|
|
||||||
|
|
||||||
statusLabel.stringValue = "Testing..."
|
|
||||||
statusLabel.textColor = .systemBlue
|
|
||||||
|
|
||||||
testButton.isEnabled = false
|
|
||||||
saveButton.isEnabled = false
|
|
||||||
|
|
||||||
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
let key = apiKeyField.stringValue
|
let key = apiKeyField.stringValue
|
||||||
let model = modelField.stringValue
|
let model = modelField.stringValue
|
||||||
@ -156,21 +139,20 @@ final class SettingsWindow: NSWindow {
|
|||||||
guard !url.isEmpty, !key.isEmpty, !model.isEmpty else {
|
guard !url.isEmpty, !key.isEmpty, !model.isEmpty else {
|
||||||
statusLabel.stringValue = "Please fill all fields"
|
statusLabel.stringValue = "Please fill all fields"
|
||||||
statusLabel.textColor = .systemRed
|
statusLabel.textColor = .systemRed
|
||||||
testButton.isEnabled = true
|
|
||||||
saveButton.isEnabled = true
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
statusLabel.stringValue = "Testing..."
|
||||||
|
statusLabel.textColor = .systemBlue
|
||||||
|
testButton.isEnabled = false
|
||||||
|
saveButton.isEnabled = false
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let refiner = LLMRefiner()
|
let refiner = LLMRefiner()
|
||||||
// 临时写入配置
|
let testConfig = LLMConfig(enabled: true, baseURL: url, apiKey: key, model: model)
|
||||||
let original = (Config.shared.llmBaseURL, Config.shared.llmAPIKey, Config.shared.llmModel)
|
|
||||||
Config.shared.llmBaseURL = url
|
|
||||||
Config.shared.llmAPIKey = key
|
|
||||||
Config.shared.llmModel = model
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await refiner.testConnection(config: Config.shared)
|
let result = try await refiner.testConnection(config: testConfig)
|
||||||
statusLabel.stringValue = "✅ Connected! Response: \(result)"
|
statusLabel.stringValue = "✅ Connected! Response: \(result)"
|
||||||
statusLabel.textColor = .systemGreen
|
statusLabel.textColor = .systemGreen
|
||||||
} catch {
|
} catch {
|
||||||
@ -178,11 +160,6 @@ final class SettingsWindow: NSWindow {
|
|||||||
statusLabel.textColor = .systemRed
|
statusLabel.textColor = .systemRed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复
|
|
||||||
Config.shared.llmBaseURL = original.0
|
|
||||||
Config.shared.llmAPIKey = original.1
|
|
||||||
Config.shared.llmModel = original.2
|
|
||||||
|
|
||||||
testButton.isEnabled = true
|
testButton.isEnabled = true
|
||||||
saveButton.isEnabled = true
|
saveButton.isEnabled = true
|
||||||
}
|
}
|
||||||
@ -193,9 +170,8 @@ final class SettingsWindow: NSWindow {
|
|||||||
let key = apiKeyField.stringValue
|
let key = apiKeyField.stringValue
|
||||||
let model = modelField.stringValue
|
let model = modelField.stringValue
|
||||||
|
|
||||||
Config.shared.llmBaseURL = url
|
let config = LLMConfig(enabled: true, baseURL: url, apiKey: key, model: model)
|
||||||
Config.shared.llmAPIKey = key
|
Config.shared.setLLMConfig(config, for: mode)
|
||||||
Config.shared.llmModel = model
|
|
||||||
|
|
||||||
statusLabel.stringValue = "✅ Saved"
|
statusLabel.stringValue = "✅ Saved"
|
||||||
statusLabel.textColor = .systemGreen
|
statusLabel.textColor = .systemGreen
|
||||||
|
|||||||
@ -18,17 +18,43 @@ enum RecognitionLanguage: String, CaseIterable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Config {
|
enum LLMMode {
|
||||||
static var shared = Config()
|
case input // Fn 键:语音输入纠错
|
||||||
|
case assist // Control 键:AI 助手对话
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LLMConfig {
|
||||||
|
var enabled: Bool
|
||||||
|
var baseURL: String
|
||||||
|
var apiKey: String
|
||||||
|
var model: String
|
||||||
|
|
||||||
|
var isConfigured: Bool {
|
||||||
|
!baseURL.isEmpty && !apiKey.isEmpty && !model.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Config {
|
||||||
|
static let shared = Config()
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
private let defaults = UserDefaults.standard
|
private let defaults = UserDefaults.standard
|
||||||
|
|
||||||
private enum Keys {
|
private enum Keys {
|
||||||
static let language = "recognition_language"
|
static let language = "recognition_language"
|
||||||
static let llmEnabled = "llm_enabled"
|
|
||||||
static let llmBaseURL = "llm_base_url"
|
// Fn 键模式(语音输入纠错)
|
||||||
static let llmAPIKey = "llm_api_key"
|
static let inputLLMEnabled = "input_llm_enabled"
|
||||||
static let llmModel = "llm_model"
|
static let inputLLMBaseURL = "input_llm_base_url"
|
||||||
|
static let inputLLMAPIKey = "input_llm_api_key"
|
||||||
|
static let inputLLMModel = "input_llm_model"
|
||||||
|
|
||||||
|
// Control 键模式(AI 助手)
|
||||||
|
static let assistLLMEnabled = "assist_llm_enabled"
|
||||||
|
static let assistLLMBaseURL = "assist_llm_base_url"
|
||||||
|
static let assistLLMAPIKey = "assist_llm_api_key"
|
||||||
|
static let assistLLMModel = "assist_llm_model"
|
||||||
}
|
}
|
||||||
|
|
||||||
var language: RecognitionLanguage {
|
var language: RecognitionLanguage {
|
||||||
@ -44,27 +70,94 @@ struct Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Fn 键模式配置(语音输入纠错)
|
||||||
|
|
||||||
|
var inputLLM: LLMConfig {
|
||||||
|
get {
|
||||||
|
LLMConfig(
|
||||||
|
enabled: defaults.bool(forKey: Keys.inputLLMEnabled),
|
||||||
|
baseURL: defaults.string(forKey: Keys.inputLLMBaseURL) ?? "",
|
||||||
|
apiKey: defaults.string(forKey: Keys.inputLLMAPIKey) ?? "",
|
||||||
|
model: defaults.string(forKey: Keys.inputLLMModel) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
defaults.set(newValue.enabled, forKey: Keys.inputLLMEnabled)
|
||||||
|
defaults.set(newValue.baseURL, forKey: Keys.inputLLMBaseURL)
|
||||||
|
defaults.set(newValue.apiKey, forKey: Keys.inputLLMAPIKey)
|
||||||
|
defaults.set(newValue.model, forKey: Keys.inputLLMModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧版本
|
||||||
var llmEnabled: Bool {
|
var llmEnabled: Bool {
|
||||||
get { defaults.bool(forKey: Keys.llmEnabled) }
|
get { inputLLM.enabled }
|
||||||
set { defaults.set(newValue, forKey: Keys.llmEnabled) }
|
set {
|
||||||
|
var cfg = inputLLM
|
||||||
|
cfg.enabled = newValue
|
||||||
|
inputLLM = cfg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var llmBaseURL: String {
|
var llmBaseURL: String {
|
||||||
get { defaults.string(forKey: Keys.llmBaseURL) ?? "" }
|
get { inputLLM.baseURL }
|
||||||
set { defaults.set(newValue, forKey: Keys.llmBaseURL) }
|
set {
|
||||||
|
var cfg = inputLLM
|
||||||
|
cfg.baseURL = newValue
|
||||||
|
inputLLM = cfg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var llmAPIKey: String {
|
var llmAPIKey: String {
|
||||||
get { defaults.string(forKey: Keys.llmAPIKey) ?? "" }
|
get { inputLLM.apiKey }
|
||||||
set { defaults.set(newValue, forKey: Keys.llmAPIKey) }
|
set {
|
||||||
|
var cfg = inputLLM
|
||||||
|
cfg.apiKey = newValue
|
||||||
|
inputLLM = cfg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var llmModel: String {
|
var llmModel: String {
|
||||||
get { defaults.string(forKey: Keys.llmModel) ?? "" }
|
get { inputLLM.model }
|
||||||
set { defaults.set(newValue, forKey: Keys.llmModel) }
|
set {
|
||||||
|
var cfg = inputLLM
|
||||||
|
cfg.model = newValue
|
||||||
|
inputLLM = cfg
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isLLMConfigured: Bool { inputLLM.isConfigured }
|
||||||
|
|
||||||
var isLLMConfigured: Bool {
|
// MARK: - Control 键模式配置(AI 助手)
|
||||||
!llmBaseURL.isEmpty && !llmAPIKey.isEmpty && !llmModel.isEmpty
|
|
||||||
|
var assistLLM: LLMConfig {
|
||||||
|
get {
|
||||||
|
LLMConfig(
|
||||||
|
enabled: defaults.bool(forKey: Keys.assistLLMEnabled),
|
||||||
|
baseURL: defaults.string(forKey: Keys.assistLLMBaseURL) ?? "",
|
||||||
|
apiKey: defaults.string(forKey: Keys.assistLLMAPIKey) ?? "",
|
||||||
|
model: defaults.string(forKey: Keys.assistLLMModel) ?? ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
defaults.set(newValue.enabled, forKey: Keys.assistLLMEnabled)
|
||||||
|
defaults.set(newValue.baseURL, forKey: Keys.assistLLMBaseURL)
|
||||||
|
defaults.set(newValue.apiKey, forKey: Keys.assistLLMAPIKey)
|
||||||
|
defaults.set(newValue.model, forKey: Keys.assistLLMModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLLMConfig(for mode: LLMMode) -> LLMConfig {
|
||||||
|
switch mode {
|
||||||
|
case .input: return inputLLM
|
||||||
|
case .assist: return assistLLM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLLMConfig(_ config: LLMConfig, for mode: LLMMode) {
|
||||||
|
switch mode {
|
||||||
|
case .input: inputLLM = config
|
||||||
|
case .assist: assistLLM = config
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user