diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift index 4721890..a8bdb26 100644 --- a/Sources/App/AppDelegate.swift +++ b/Sources/App/AppDelegate.swift @@ -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,41 +145,86 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard isRecording else { return } isRecording = false - // 停止音频和识别 audioMonitor.stop() _ = speechRecognizer.stop() let text = finalTranscription.trimmingCharacters(in: .whitespacesAndNewlines) - - // 如果 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 { + 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 + } + + recordingPanel.showRefining() + + Task { @MainActor in + do { + let result = try await llmRefiner.refine(text: text, config: config) + recordingPanel.hideAnimated { + TextInjector.inject(result.text) + } + } catch { + print("[App] LLM refine error: \(error)") + recordingPanel.hideAnimated { + TextInjector.inject(text) + } + } + } + } + + // MARK: - Assist Mode (Control) + + private func handleAssistMode(text: String, config: LLMConfig) { + guard config.enabled && config.isConfigured else { + recordingPanel.updateText("AI 助手未配置") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in + self?.recordingPanel.hideAnimated() + } + return + } + + recordingPanel.showThinking() + + Task { @MainActor in + do { + let result = try await llmRefiner.chat(text: text, config: config) + recordingPanel.hideAnimated { + TextInjector.inject(result.text) + } + } catch { + print("[App] LLM chat error: \(error)") + recordingPanel.updateText("请求失败,请检查配置") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.recordingPanel.hideAnimated() + } + } + } + } + private func cancelRecording() { audioMonitor.stop() speechRecognizer.cancel() diff --git a/Sources/App/MenuBarController.swift b/Sources/App/MenuBarController.swift index 98745b4..b362a59 100644 --- a/Sources/App/MenuBarController.swift +++ b/Sources/App/MenuBarController.swift @@ -7,20 +7,38 @@ 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() @@ -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) } diff --git a/Sources/Core/KeyMonitor.swift b/Sources/Core/KeyMonitor.swift index 99bb0b5..ec29ee9 100644 --- a/Sources/Core/KeyMonitor.swift +++ b/Sources/Core/KeyMonitor.swift @@ -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? { 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? { - if !isFnPressed { - isFnPressed = true - DispatchQueue.main.async { [weak self] in - self?.onFnDown?() - } - } - return nil - } - - private func handleFnUp() -> Unmanaged? { - if isFnPressed { - isFnPressed = false - DispatchQueue.main.async { [weak self] in - self?.onFnUp?() - } - } - return nil - } } diff --git a/Sources/Core/LLMRefiner.swift b/Sources/Core/LLMRefiner.swift index 49a0c4f..45201b8 100644 --- a/Sources/Core/LLMRefiner.swift +++ b/Sources/Core/LLMRefiner.swift @@ -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. 修正中文同音字/词错误。语音识别经常把技术词汇识别为同音的日常词汇,例如: @@ -44,6 +31,46 @@ final class LLMRefiner { 7. 保留所有标点符号、空格、大小写和格式。 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], @@ -51,7 +78,7 @@ final class LLMRefiner { ] 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 } } diff --git a/Sources/UI/RecordingPanel.swift b/Sources/UI/RecordingPanel.swift index d8a5c56..75c303b 100644 --- a/Sources/UI/RecordingPanel.swift +++ b/Sources/UI/RecordingPanel.swift @@ -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 } diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift index 8cc5557..efcfc67 100644 --- a/Sources/UI/SettingsWindow.swift +++ b/Sources/UI/SettingsWindow.swift @@ -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,11 +88,10 @@ final class SettingsWindow: NSWindow { statusLabel.maximumNumberOfLines = 2 statusLabel.translatesAutoresizingMaskIntoConstraints = false - // 布局 let grid = NSGridView(views: [ [baseURLLabel, baseURLField], - [apiKeyLabel, apiKeyField], - [modelLabel, modelField] + [apiKeyLabel, apiKeyField], + [modelLabel, modelField] ]) grid.translatesAutoresizingMaskIntoConstraints = false grid.column(at: 0).xPlacement = .trailing @@ -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 diff --git a/Sources/Utils/Config.swift b/Sources/Utils/Config.swift index 14300be..a053f88 100644 --- a/Sources/Utils/Config.swift +++ b/Sources/Utils/Config.swift @@ -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 { inputLLM.isConfigured } - var isLLMConfigured: Bool { - !llmBaseURL.isEmpty && !llmAPIKey.isEmpty && !llmModel.isEmpty + // 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 + } } } diff --git a/VoiceInput-20260424.dmg b/VoiceInput-20260424.dmg index c03bfda..aaed257 100644 Binary files a/VoiceInput-20260424.dmg and b/VoiceInput-20260424.dmg differ