feat: 双模式 LLM - Fn 语音输入 + Control AI 助手,独立配置
This commit is contained in:
parent
0c1482c983
commit
67b3024bda
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Loading…
Reference in New Issue
Block a user