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 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()

View File

@ -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)
} }

View File

@ -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
}
} }

View File

@ -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
} }
} }

View File

@ -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 }

View File

@ -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

View File

@ -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.