feat: 双模式 LLM - Fn 语音输入 + Control AI 助手,独立配置

This commit is contained in:
JOJO 2026-04-24 18:14:38 +08:00
parent 0c1482c983
commit 67b3024bda
8 changed files with 432 additions and 200 deletions

View File

@ -1,6 +1,6 @@
import Cocoa
/// LLM
/// Fn Control AI
final class AppDelegate: NSObject, NSApplicationDelegate {
private let keyMonitor = KeyMonitor()
@ -12,6 +12,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var isRecording = false
private var finalTranscription: String = ""
private var currentMode: LLMMode = .input
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
@ -29,14 +30,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// 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
Voice Input Fn Control
1.
@ -50,13 +49,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
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 正在运行。"
@ -77,13 +74,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return
}
// Fn
keyMonitor.onFnDown = { [weak self] in
self?.currentMode = .input
self?.startRecording()
}
keyMonitor.onFnUp = { [weak self] in
self?.stopRecording()
}
// Control AI
keyMonitor.onControlDown = { [weak self] in
self?.currentMode = .assist
self?.startRecording()
}
keyMonitor.onControlUp = { [weak self] in
self?.stopRecording()
}
}
// MARK: - Recording
@ -94,12 +101,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
finalTranscription = ""
recordingPanel.showAnimated()
recordingPanel.updateText("Listening...")
//
let promptText = currentMode == .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
@ -107,7 +116,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}
//
let speechOK = await speechRecognizer.start(language: language)
if !speechOK {
recordingPanel.updateText("Recognition error")
@ -125,18 +133,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
speechRecognizer.onError = { error in
let nsError = error as NSError
// kAFAssistantErrorDomain code 1110 =
if nsError.domain == "kAFAssistantErrorDomain" && nsError.code == 1110 {
return
}
print("[App] Speech error: \(error)")
}
speechRecognizer.onStateChange = { [weak self] isActive in
if !isActive, self?.isRecording == true {
//
}
}
}
}
@ -144,39 +145,84 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
guard isRecording else { return }
isRecording = false
//
audioMonitor.stop()
_ = speechRecognizer.stop()
let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
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 = ""
}
// MARK: - Input Mode (Fn)
private func handleInputMode(text: String, config: LLMConfig) {
guard config.enabled && config.isConfigured else {
recordingPanel.hideAnimated {
TextInjector.inject(text)
}
return
}
// 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)
let result = try await llmRefiner.refine(text: text, config: config)
recordingPanel.hideAnimated {
TextInjector.inject(result.refinedText)
TextInjector.inject(result.text)
}
} catch {
print("[App] LLM error: \(error)")
// LLM 退
print("[App] LLM refine error: \(error)")
recordingPanel.hideAnimated {
TextInjector.inject(text)
}
}
}
} else if !text.isEmpty {
recordingPanel.hideAnimated {
TextInjector.inject(text)
}
} else {
recordingPanel.hideAnimated()
}
finalTranscription = ""
// 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() {

View File

@ -7,21 +7,39 @@ final class MenuBarController: NSObject {
private let menu = NSMenu()
private var languageMenuItems: [NSMenuItem] = []
//
private let llmSubmenu = NSMenu()
private let llmEnabledItem: NSMenuItem
private let settingsWindow = SettingsWindow()
// Input LLM (Fn - )
private let inputLLMSubmenu = NSMenu()
private let inputLLMEnabledItem: NSMenuItem
// Assist LLM (Control - AI )
private let assistLLMSubmenu = NSMenu()
private let assistLLMEnabledItem: NSMenuItem
//
private let inputSettingsWindow: SettingsWindow
private let assistSettingsWindow: SettingsWindow
override init() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
// LLM /
llmEnabledItem = NSMenuItem(
title: "Enable LLM Refinement",
action: #selector(toggleLLM),
// Input LLM
inputLLMEnabledItem = NSMenuItem(
title: "Enable",
action: #selector(toggleInputLLM),
keyEquivalent: ""
)
// Assist LLM
assistLLMEnabledItem = NSMenuItem(
title: "Enable",
action: #selector(toggleAssistLLM),
keyEquivalent: ""
)
//
inputSettingsWindow = SettingsWindow(mode: .input)
assistSettingsWindow = SettingsWindow(mode: .assist)
super.init()
configureStatusItem()
@ -70,24 +88,45 @@ final class MenuBarController: NSObject {
menu.addItem(.separator())
// LLM
llmSubmenu.addItem(llmEnabledItem)
llmEnabledItem.target = self
llmEnabledItem.action = #selector(toggleLLM)
updateLLMMenuState()
// ===== Input LLM (Fn - ) =====
inputLLMSubmenu.addItem(inputLLMEnabledItem)
inputLLMEnabledItem.target = self
inputLLMEnabledItem.action = #selector(toggleInputLLM)
updateInputLLMMenuState()
let settingsItem = NSMenuItem(
title: "LLM Settings...",
action: #selector(openSettings),
keyEquivalent: ","
let inputSettingsItem = NSMenuItem(
title: "Settings...",
action: #selector(openInputSettings),
keyEquivalent: ""
)
settingsItem.target = self
llmSubmenu.addItem(settingsItem)
inputSettingsItem.target = self
inputLLMSubmenu.addItem(inputSettingsItem)
let llmSubmenuItem = NSMenuItem()
llmSubmenuItem.title = "LLM Refinement"
llmSubmenuItem.submenu = llmSubmenu
menu.addItem(llmSubmenuItem)
let inputSubmenuItem = NSMenuItem()
inputSubmenuItem.title = "🎤 Input LLM (Fn)"
inputSubmenuItem.submenu = inputLLMSubmenu
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())
@ -103,8 +142,12 @@ final class MenuBarController: NSObject {
statusItem.menu = menu
}
private func updateLLMMenuState() {
llmEnabledItem.state = Config.shared.llmEnabled ? .on : .off
private func updateInputLLMMenuState() {
inputLLMEnabledItem.state = Config.shared.inputLLM.enabled ? .on : .off
}
private func updateAssistLLMMenuState() {
assistLLMEnabledItem.state = Config.shared.assistLLM.enabled ? .on : .off
}
private func updateLanguageMenuState() {
@ -125,14 +168,29 @@ final class MenuBarController: NSObject {
print("[Menu] Language switched to: \(lang.displayName)")
}
@objc private func toggleLLM() {
Config.shared.llmEnabled.toggle()
updateLLMMenuState()
print("[Menu] LLM Refinement: \(Config.shared.llmEnabled ? "ON" : "OFF")")
@objc private func toggleInputLLM() {
var config = Config.shared.inputLLM
config.enabled.toggle()
Config.shared.inputLLM = config
updateInputLLMMenuState()
print("[Menu] Input LLM: \(config.enabled ? "ON" : "OFF")")
}
@objc private func openSettings() {
settingsWindow.makeKeyAndOrderFront(nil)
@objc private func toggleAssistLLM() {
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)
}

View File

@ -1,18 +1,23 @@
import Cocoa
import CoreGraphics
/// Fn / Globe
/// Fn + Control AI
final class KeyMonitor {
// Fn CGEventFlags.maskSecondaryFn = 0x00800000
// Fn NX_SECONDARYFNMASK
private static let fnMask: UInt64 = 0x00800000
// Control NX_CONTROLMASK
private static let controlMask: UInt64 = 0x00001000
private var eventTap: CFMachPort?
private var runLoopSource: CFRunLoopSource?
private var isFnPressed = false
private var isControlPressed = false
var onFnDown: (() -> Void)?
var onFnUp: (() -> Void)?
var onControlDown: (() -> Void)?
var onControlUp: (() -> Void)?
func start() -> Bool {
guard eventTap == nil else { return true }
@ -32,7 +37,6 @@ final class KeyMonitor {
return monitor.handleEvent(proxy: proxy, type: type, event: event)
}
// HID tap Fn
var tap = CGEvent.tapCreate(
tap: .cghidEventTap,
place: .headInsertEventTap,
@ -43,7 +47,6 @@ final class KeyMonitor {
)
if tap == nil {
// 退 tap
print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap")
tap = CGEvent.tapCreate(
tap: .cgSessionEventTap,
@ -65,7 +68,7 @@ final class KeyMonitor {
CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
CGEvent.tapEnable(tap: tap, enable: true)
print("[KeyMonitor] ✅ Started")
print("[KeyMonitor] ✅ Started (Fn + Control)")
return true
}
@ -82,24 +85,43 @@ final class KeyMonitor {
private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged<CGEvent>? {
let flags = event.flags.rawValue
_ = event.getIntegerValueField(.keyboardEventKeycode)
let fnInFlags = (flags & Self.fnMask) != 0
let controlInFlags = (flags & Self.controlMask) != 0
switch type {
case .flagsChanged:
// Fn
if fnInFlags && !isFnPressed {
print("[KeyMonitor] ⚡️ Fn DOWN (flags=0x\(String(flags, radix: 16)))")
return handleFnDown()
isFnPressed = true
DispatchQueue.main.async { [weak self] in
self?.onFnDown?()
}
return nil
}
if !fnInFlags && isFnPressed {
print("[KeyMonitor] ⚡️ Fn UP (flags=0x\(String(flags, radix: 16)))")
return handleFnUp()
isFnPressed = false
DispatchQueue.main.async { [weak self] in
self?.onFnUp?()
}
return nil
}
return Unmanaged.passUnretained(event)
case .keyDown, .keyUp:
let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
if keyCode == 63 && fnInFlags {
return handleFnDown()
// 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)
@ -107,24 +129,4 @@ final class KeyMonitor {
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
}
}

View File

@ -1,36 +1,23 @@
import Foundation
/// OpenAI API
/// OpenAI API LLM
final class LLMRefiner {
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.timeoutIntervalForRequest = 15
config.timeoutIntervalForResource = 20
config.timeoutIntervalForRequest = 30
config.timeoutIntervalForResource = 60
return URLSession(configuration: config)
}()
///
struct RefinementResult {
let refinedText: String
let text: String
let wasChanged: Bool
}
///
/// - 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")
// MARK: - Fn
func refine(text: String, config: LLMConfig) async throws -> RefinementResult {
let systemPrompt = """
1. /
@ -45,13 +32,53 @@ final class LLMRefiner {
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]] = [
["role": "system", "content": systemPrompt],
["role": "user", "content": text]
]
let body: [String: Any] = [
"model": config.llmModel,
"model": config.model,
"messages": messages,
"max_tokens": 4096,
"thinking": ["type": "disabled"]
@ -76,22 +103,25 @@ final class LLMRefiner {
let choices = json["choices"] as? [[String: Any]],
let firstChoice = choices.first,
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,
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)
print("[LLM] Refinement \(wasChanged ? "changed" : "unchanged"): \"\(trimmed.prefix(80))...\"")
return RefinementResult(refinedText: trimmed, wasChanged: wasChanged)
return RefinementResult(text: trimmed, wasChanged: wasChanged)
}
///
func testConnection(config: Config) async throws -> String {
func testConnection(config: LLMConfig) async throws -> String {
let testText = "测试连接"
let result = try await refine(text: testText, config: config)
return result.refinedText
let result = try await callLLM(
text: testText,
systemPrompt: "You are a helpful assistant. Reply with 'OK' only.",
config: config
)
return result.text
}
}

View File

@ -231,6 +231,33 @@ final class RecordingPanel: NSPanel {
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
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }

View File

@ -1,8 +1,9 @@
import Cocoa
/// LLM
/// LLM
final class SettingsWindow: NSWindow {
private let mode: LLMMode
private let baseURLField: NSTextField
private let apiKeyField: NSSecureTextField
private let modelField: NSTextField
@ -10,8 +11,9 @@ final class SettingsWindow: NSWindow {
private let saveButton: NSButton
private let statusLabel: NSTextField
init() {
//
init(mode: LLMMode) {
self.mode = mode
baseURLField = NSTextField(frame: .zero)
apiKeyField = NSSecureTextField(frame: .zero)
modelField = NSTextField(frame: .zero)
@ -33,7 +35,7 @@ final class SettingsWindow: NSWindow {
}
private func configureWindow() {
title = "LLM Settings"
title = mode == .input ? "Input LLM Settings (Fn)" : "Assist LLM Settings (Control)"
isReleasedWhenClosed = false
center()
}
@ -41,7 +43,6 @@ final class SettingsWindow: NSWindow {
private func buildUI() {
guard let content = contentView else { return }
//
func makeLabel(_ text: String) -> NSTextField {
let label = NSTextField(labelWithString: text)
label.font = .systemFont(ofSize: 11, weight: .semibold)
@ -51,20 +52,10 @@ final class SettingsWindow: NSWindow {
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 apiKeyLabel = makeLabel("API Key:")
let modelLabel = makeLabel("Model:")
// 使
baseURLField.placeholderString = "https://api.openai.com"
baseURLField.font = .systemFont(ofSize: 13)
baseURLField.bezelStyle = .roundedBezel
@ -75,7 +66,7 @@ final class SettingsWindow: NSWindow {
apiKeyField.bezelStyle = .roundedBezel
apiKeyField.translatesAutoresizingMaskIntoConstraints = false
modelField.placeholderString = "gpt-3.5-turbo"
modelField.placeholderString = mode == .input ? "deepseek-v4-flash" : "gpt-4"
modelField.font = .systemFont(ofSize: 13)
modelField.bezelStyle = .roundedBezel
modelField.translatesAutoresizingMaskIntoConstraints = false
@ -97,7 +88,6 @@ final class SettingsWindow: NSWindow {
statusLabel.maximumNumberOfLines = 2
statusLabel.translatesAutoresizingMaskIntoConstraints = false
//
let grid = NSGridView(views: [
[baseURLLabel, baseURLField],
[apiKeyLabel, apiKeyField],
@ -135,20 +125,13 @@ final class SettingsWindow: NSWindow {
}
private func loadCurrentConfig() {
baseURLField.stringValue = Config.shared.llmBaseURL
apiKeyField.stringValue = Config.shared.llmAPIKey
modelField.stringValue = Config.shared.llmModel
let config = Config.shared.getLLMConfig(for: mode)
baseURLField.stringValue = config.baseURL
apiKeyField.stringValue = config.apiKey
modelField.stringValue = config.model
}
@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 key = apiKeyField.stringValue
let model = modelField.stringValue
@ -156,21 +139,20 @@ final class SettingsWindow: NSWindow {
guard !url.isEmpty, !key.isEmpty, !model.isEmpty else {
statusLabel.stringValue = "Please fill all fields"
statusLabel.textColor = .systemRed
testButton.isEnabled = true
saveButton.isEnabled = true
return
}
statusLabel.stringValue = "Testing..."
statusLabel.textColor = .systemBlue
testButton.isEnabled = false
saveButton.isEnabled = false
Task { @MainActor in
let refiner = LLMRefiner()
//
let original = (Config.shared.llmBaseURL, Config.shared.llmAPIKey, Config.shared.llmModel)
Config.shared.llmBaseURL = url
Config.shared.llmAPIKey = key
Config.shared.llmModel = model
let testConfig = LLMConfig(enabled: true, baseURL: url, apiKey: key, model: model)
do {
let result = try await refiner.testConnection(config: Config.shared)
let result = try await refiner.testConnection(config: testConfig)
statusLabel.stringValue = "✅ Connected! Response: \(result)"
statusLabel.textColor = .systemGreen
} catch {
@ -178,11 +160,6 @@ final class SettingsWindow: NSWindow {
statusLabel.textColor = .systemRed
}
//
Config.shared.llmBaseURL = original.0
Config.shared.llmAPIKey = original.1
Config.shared.llmModel = original.2
testButton.isEnabled = true
saveButton.isEnabled = true
}
@ -193,9 +170,8 @@ final class SettingsWindow: NSWindow {
let key = apiKeyField.stringValue
let model = modelField.stringValue
Config.shared.llmBaseURL = url
Config.shared.llmAPIKey = key
Config.shared.llmModel = model
let config = LLMConfig(enabled: true, baseURL: url, apiKey: key, model: model)
Config.shared.setLLMConfig(config, for: mode)
statusLabel.stringValue = "✅ Saved"
statusLabel.textColor = .systemGreen

View File

@ -18,17 +18,43 @@ enum RecognitionLanguage: String, CaseIterable {
}
}
struct Config {
static var shared = Config()
enum LLMMode {
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 enum Keys {
static let language = "recognition_language"
static let llmEnabled = "llm_enabled"
static let llmBaseURL = "llm_base_url"
static let llmAPIKey = "llm_api_key"
static let llmModel = "llm_model"
// Fn
static let inputLLMEnabled = "input_llm_enabled"
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 {
@ -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 {
get { defaults.bool(forKey: Keys.llmEnabled) }
set { defaults.set(newValue, forKey: Keys.llmEnabled) }
get { inputLLM.enabled }
set {
var cfg = inputLLM
cfg.enabled = newValue
inputLLM = cfg
}
}
var llmBaseURL: String {
get { defaults.string(forKey: Keys.llmBaseURL) ?? "" }
set { defaults.set(newValue, forKey: Keys.llmBaseURL) }
get { inputLLM.baseURL }
set {
var cfg = inputLLM
cfg.baseURL = newValue
inputLLM = cfg
}
}
var llmAPIKey: String {
get { defaults.string(forKey: Keys.llmAPIKey) ?? "" }
set { defaults.set(newValue, forKey: Keys.llmAPIKey) }
get { inputLLM.apiKey }
set {
var cfg = inputLLM
cfg.apiKey = newValue
inputLLM = cfg
}
}
var llmModel: String {
get { defaults.string(forKey: Keys.llmModel) ?? "" }
set { defaults.set(newValue, forKey: Keys.llmModel) }
get { inputLLM.model }
set {
var cfg = inputLLM
cfg.model = newValue
inputLLM = cfg
}
}
var isLLMConfigured: Bool {
!llmBaseURL.isEmpty && !llmAPIKey.isEmpty && !llmModel.isEmpty
var isLLMConfigured: Bool { inputLLM.isConfigured }
// MARK: - Control AI
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.