commit 1d9c9d5fa03a8ed43f30972c7c1d501fc3722411
Author: JOJO <1498581755@qq.com>
Date: Fri Apr 24 16:17:57 2026 +0800
feat: macOS 菜单栏语音输入应用初始版本
- CGEvent cghidEventTap 全局监听 Fn 键
- Apple Speech 流式语音识别(zh-CN 默认)
- 实时音频 RMS 电平驱动波形动画
- NSPanel 胶囊浮动窗口
- 剪贴板 + Cmd+V 文本注入,CJK 输入法自动切换
- OpenAI 兼容 LLM 纠错(DeepSeek 预设)
- LSUIElement 菜单栏运行
- SPM + Makefile 构建
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..38f6021
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+.build/
+.swiftpm/
+*.app
\ No newline at end of file
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..4497d2e
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,84 @@
+# VoiceInput Makefile
+# macOS 14+ 菜单栏语音输入应用
+
+APP_NAME := VoiceInput
+BUILD_DIR := .build
+RELEASE_DIR := $(BUILD_DIR)/release
+PRODUCT := $(RELEASE_DIR)/$(APP_NAME)
+APP_BUNDLE := $(RELEASE_DIR)/$(APP_NAME).app
+INSTALL_DIR := /Applications/$(APP_NAME).app
+ENTITLEMENTS := Sources/Resources/VoiceInput.entitlements
+
+.PHONY: all build run install clean debug release
+
+all: build
+
+# ============================================================
+# Build
+# ============================================================
+build: debug
+
+debug:
+ @echo "🔨 Building Debug..."
+ swift build -c debug --disable-sandbox
+ @echo "📦 Creating app bundle..."
+ @./scripts/create_app_bundle.sh debug
+ @echo "✅ Build complete: $(APP_BUNDLE)"
+
+release:
+ @echo "🔨 Building Release..."
+ swift build -c release --disable-sandbox
+ @echo "📦 Creating app bundle..."
+ @./scripts/create_app_bundle.sh release
+ @echo "✅ Release build complete: $(APP_BUNDLE)"
+
+# ============================================================
+# Run
+# ============================================================
+run: debug
+ @echo "🚀 Launching..."
+ open $(APP_BUNDLE)
+
+# ============================================================
+# Install
+# ============================================================
+install: release
+ @echo "📥 Installing to $(INSTALL_DIR)..."
+ @if [ -d "$(INSTALL_DIR)" ]; then rm -rf "$(INSTALL_DIR)"; fi
+ cp -R $(APP_BUNDLE) $(INSTALL_DIR)
+ @echo "✅ Installed to $(INSTALL_DIR)"
+
+# ============================================================
+# Clean
+# ============================================================
+clean:
+ @echo "🧹 Cleaning..."
+ swift package clean
+ rm -rf $(RELEASE_DIR)
+ @echo "✅ Clean"
+
+# ============================================================
+# Entitlements
+# ============================================================
+entitlements: $(ENTITLEMENTS)
+
+$(ENTITLEMENTS):
+ @mkdir -p Sources/Resources
+ @echo '' > $(ENTITLEMENTS)
+ @echo '' >> $(ENTITLEMENTS)
+ @echo '' >> $(ENTITLEMENTS)
+ @echo '' >> $(ENTITLEMENTS)
+ @echo ' com.apple.security.device.audio-input' >> $(ENTITLEMENTS)
+ @echo ' ' >> $(ENTITLEMENTS)
+ @echo ' com.apple.security.cs.disable-library-validation' >> $(ENTITLEMENTS)
+ @echo ' ' >> $(ENTITLEMENTS)
+ @echo '' >> $(ENTITLEMENTS)
+ @echo '' >> $(ENTITLEMENTS)
+
+# ============================================================
+# Codesign (ad-hoc)
+# ============================================================
+sign: release
+ @echo "🔏 Signing..."
+ codesign --force --deep --sign - $(APP_BUNDLE)
+ @echo "✅ Signed (ad-hoc)"
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..be041b0
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,21 @@
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "VoiceInput",
+ platforms: [.macOS(.v14)],
+ targets: [
+ .executableTarget(
+ name: "VoiceInput",
+ path: "Sources",
+ linkerSettings: [
+ .linkedFramework("Speech"),
+ .linkedFramework("AVFAudio"),
+ .linkedFramework("Carbon"),
+ .linkedFramework("CoreGraphics"),
+ .linkedFramework("AppKit"),
+ .linkedFramework("Foundation")
+ ]
+ )
+ ]
+)
diff --git a/Sources/App/AppDelegate.swift b/Sources/App/AppDelegate.swift
new file mode 100644
index 0000000..d223bfc
--- /dev/null
+++ b/Sources/App/AppDelegate.swift
@@ -0,0 +1,159 @@
+import Cocoa
+
+/// 应用主控制器,协调按键监听、语音识别、LLM 纠错和文本注入
+final class AppDelegate: NSObject, NSApplicationDelegate {
+
+ private let keyMonitor = KeyMonitor()
+ private let speechRecognizer = SpeechRecognizer()
+ private let audioMonitor = AudioLevelMonitor()
+ private let recordingPanel = RecordingPanel()
+ private let menuBarController = MenuBarController()
+ private let llmRefiner = LLMRefiner()
+
+ private var isRecording = false
+ private var finalTranscription: String = ""
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ NSApp.setActivationPolicy(.accessory)
+
+ // 启动按键监听
+ let started = keyMonitor.start()
+ if !started {
+ showPermissionAlert()
+ }
+
+ keyMonitor.onFnDown = { [weak self] in
+ self?.startRecording()
+ }
+
+ keyMonitor.onFnUp = { [weak self] in
+ self?.stopRecording()
+ }
+
+ print("[App] Ready")
+ }
+
+ // MARK: - Recording
+
+ private func startRecording() {
+ guard !isRecording else { return }
+ isRecording = true
+ finalTranscription = ""
+
+ recordingPanel.showAnimated()
+ recordingPanel.updateText("Listening...")
+
+ let language = Config.shared.language.rawValue
+
+ Task { @MainActor in
+ // 启动音频电平监测
+ let audioOK = audioMonitor.start()
+ if audioOK {
+ audioMonitor.onLevelUpdate = { [weak self] level in
+ self?.recordingPanel.waveformView.updateLevel(level)
+ }
+ }
+
+ // 启动语音识别
+ let speechOK = await speechRecognizer.start(language: language)
+ if !speechOK {
+ recordingPanel.updateText("Recognition error")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ self?.cancelRecording()
+ }
+ return
+ }
+
+ speechRecognizer.onResult = { [weak self] text in
+ guard let self = self, self.isRecording else { return }
+ self.finalTranscription = text
+ self.recordingPanel.updateText(text)
+ }
+
+ speechRecognizer.onError = { [weak self] error in
+ print("[App] Speech error: \(error)")
+ self?.recordingPanel.updateText("Error: \(error.localizedDescription)")
+ }
+
+ speechRecognizer.onStateChange = { [weak self] isActive in
+ if !isActive, self?.isRecording == true {
+ // 识别意外停止
+ }
+ }
+ }
+ }
+
+ private func stopRecording() {
+ 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 {
+ recordingPanel.hideAnimated()
+ }
+
+ finalTranscription = ""
+ }
+
+ private func cancelRecording() {
+ audioMonitor.stop()
+ speechRecognizer.cancel()
+ recordingPanel.hideAnimated()
+ isRecording = false
+ finalTranscription = ""
+ }
+
+ // MARK: - Permissions
+
+ private func showPermissionAlert() {
+ let alert = NSAlert()
+ alert.messageText = "Accessibility Permission Required"
+ alert.informativeText = """
+ Voice Input needs Accessibility permission to detect the Fn key globally.
+
+ Please go to System Settings > Privacy & Security > Accessibility
+ and enable Voice Input.
+ """
+ alert.alertStyle = .warning
+ alert.addButton(withTitle: "Open System Settings")
+ alert.addButton(withTitle: "Later")
+
+ if alert.runModal() == .alertFirstButtonReturn {
+ NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!)
+ }
+ }
+
+ func applicationWillTerminate(_ notification: Notification) {
+ keyMonitor.stop()
+ audioMonitor.stop()
+ speechRecognizer.cancel()
+ recordingPanel.close()
+ }
+}
diff --git a/Sources/App/MenuBarController.swift b/Sources/App/MenuBarController.swift
new file mode 100644
index 0000000..98745b4
--- /dev/null
+++ b/Sources/App/MenuBarController.swift
@@ -0,0 +1,142 @@
+import Cocoa
+
+/// 菜单栏控制器:管理状态栏图标和菜单
+final class MenuBarController: NSObject {
+
+ private let statusItem: NSStatusItem
+ private let menu = NSMenu()
+ private var languageMenuItems: [NSMenuItem] = []
+
+ // 子菜单引用
+ private let llmSubmenu = NSMenu()
+ private let llmEnabledItem: NSMenuItem
+ private let settingsWindow = SettingsWindow()
+
+ override init() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+
+ // LLM 启用/禁用
+ llmEnabledItem = NSMenuItem(
+ title: "Enable LLM Refinement",
+ action: #selector(toggleLLM),
+ keyEquivalent: ""
+ )
+
+ super.init()
+
+ configureStatusItem()
+ buildMenu()
+ }
+
+ private func configureStatusItem() {
+ if let button = statusItem.button {
+ button.image = NSImage(
+ systemSymbolName: "mic.fill",
+ accessibilityDescription: "Voice Input"
+ )
+ button.toolTip = "Voice Input — 按住 Fn 键录音"
+ }
+ }
+
+ private func buildMenu() {
+ // 当前语言显示
+ let currentLanguageItem = NSMenuItem(
+ title: "Language: \(Config.shared.language.displayName)",
+ action: nil,
+ keyEquivalent: ""
+ )
+ currentLanguageItem.isEnabled = false
+ menu.addItem(currentLanguageItem)
+ menu.addItem(.separator())
+
+ // 语言选择子菜单
+ let languageMenu = NSMenu()
+ for lang in RecognitionLanguage.allCases {
+ let item = NSMenuItem(
+ title: lang.displayName,
+ action: #selector(selectLanguage(_:)),
+ keyEquivalent: ""
+ )
+ item.representedObject = lang
+ item.state = lang == Config.shared.language ? .on : .off
+ languageMenu.addItem(item)
+ languageMenuItems.append(item)
+ }
+
+ let languageSubmenuItem = NSMenuItem()
+ languageSubmenuItem.title = "Language"
+ languageSubmenuItem.submenu = languageMenu
+ menu.addItem(languageSubmenuItem)
+
+ menu.addItem(.separator())
+
+ // LLM 子菜单
+ llmSubmenu.addItem(llmEnabledItem)
+ llmEnabledItem.target = self
+ llmEnabledItem.action = #selector(toggleLLM)
+ updateLLMMenuState()
+
+ let settingsItem = NSMenuItem(
+ title: "LLM Settings...",
+ action: #selector(openSettings),
+ keyEquivalent: ","
+ )
+ settingsItem.target = self
+ llmSubmenu.addItem(settingsItem)
+
+ let llmSubmenuItem = NSMenuItem()
+ llmSubmenuItem.title = "LLM Refinement"
+ llmSubmenuItem.submenu = llmSubmenu
+ menu.addItem(llmSubmenuItem)
+
+ menu.addItem(.separator())
+
+ // 退出
+ let quitItem = NSMenuItem(
+ title: "Quit",
+ action: #selector(quit),
+ keyEquivalent: "q"
+ )
+ quitItem.target = self
+ menu.addItem(quitItem)
+
+ statusItem.menu = menu
+ }
+
+ private func updateLLMMenuState() {
+ llmEnabledItem.state = Config.shared.llmEnabled ? .on : .off
+ }
+
+ private func updateLanguageMenuState() {
+ for item in languageMenuItems {
+ guard let lang = item.representedObject as? RecognitionLanguage else { continue }
+ item.state = lang == Config.shared.language ? .on : .off
+ }
+ // 更新第一项的显示
+ if let first = menu.items.first {
+ first.title = "Language: \(Config.shared.language.displayName)"
+ }
+ }
+
+ @objc private func selectLanguage(_ sender: NSMenuItem) {
+ guard let lang = sender.representedObject as? RecognitionLanguage else { return }
+ Config.shared.language = lang
+ updateLanguageMenuState()
+ 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 openSettings() {
+ settingsWindow.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ @objc private func quit() {
+ NSApp.terminate(nil)
+ }
+}
diff --git a/Sources/Core/AudioLevelMonitor.swift b/Sources/Core/AudioLevelMonitor.swift
new file mode 100644
index 0000000..c2e509f
--- /dev/null
+++ b/Sources/Core/AudioLevelMonitor.swift
@@ -0,0 +1,66 @@
+import AVFAudio
+import Foundation
+
+/// 实时音频电平监测,通过 AVAudioEngine 获取麦克风 RMS 电平
+final class AudioLevelMonitor {
+
+ private let engine = AVAudioEngine()
+ private var isRunning = false
+
+ /// RMS 电平回调,值范围约 0.0 ~ 1.0(正常语音),可能超过 1.0
+ var onLevelUpdate: ((Float) -> Void)?
+
+ func start() -> Bool {
+ guard !isRunning else { return true }
+
+ let inputNode = engine.inputNode
+ let format = inputNode.outputFormat(forBus: 0)
+
+ // 安装 tap 获取实时音频缓冲
+ inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
+ self?.processAudioBuffer(buffer)
+ }
+
+ do {
+ try engine.start()
+ isRunning = true
+ print("[AudioLevel] Started")
+ return true
+ } catch {
+ print("[AudioLevel] Failed to start engine: \(error)")
+ return false
+ }
+ }
+
+ func stop() {
+ guard isRunning else { return }
+ engine.inputNode.removeTap(onBus: 0)
+ engine.stop()
+ isRunning = false
+ print("[AudioLevel] Stopped")
+ }
+
+ private func processAudioBuffer(_ buffer: AVAudioPCMBuffer) {
+ guard let channelData = buffer.floatChannelData else { return }
+ let frameLength = Int(buffer.frameLength)
+ let channelCount = Int(buffer.format.channelCount)
+
+ // 计算所有通道的 RMS
+ var sumSquares: Float = 0
+ for channel in 0.. String? {
+ guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else {
+ return nil
+ }
+ return TISGetInputSourceProperty(source, kTISPropertyInputSourceID)
+ .flatMap { Unmanaged.fromOpaque($0).takeUnretainedValue() as String }
+ }
+
+ /// 判断当前输入源是否为 CJK 输入法
+ static func isCJKInputSource() -> Bool {
+ guard let id = currentInputSourceID() else { return false }
+ let cjkPrefixes = [
+ "com.apple.inputmethod.Kotoeri", // 日语
+ "com.apple.inputmethod.SCIM", // 简体中文
+ "com.apple.inputmethod.TCIM", // 繁体中文
+ "com.apple.inputmethod.Korean", // 韩语
+ "com.apple.inputmethod.2SetHangul",
+ "com.apple.inputmethod.3SetHangul",
+ "com.apple.inputmethod.390Hangul",
+ "com.apple.inputmethod.GongjinCheong",
+ "com.apple.inputmethod.HNC"
+ ]
+ return cjkPrefixes.contains(where: { id.hasPrefix($0) })
+ }
+
+ /// 查找 ASCII 输入源(ABC 键盘)
+ static func findASCIIInputSource() -> TISInputSource? {
+ let properties: [CFString: Any] = [
+ kTISPropertyInputSourceType: kTISTypeKeyboardLayout as CFString,
+ kTISPropertyInputSourceIsASCIICapable: true as CFBoolean
+ ]
+ guard let list = TISCreateInputSourceList(properties as CFDictionary, false)?
+ .takeRetainedValue() as? [TISInputSource] else {
+ return nil
+ }
+ // 优先选择 "ABC" 或 "U.S."
+ for source in list {
+ if let id = TISGetInputSourceProperty(source, kTISPropertyInputSourceID)
+ .flatMap({ Unmanaged.fromOpaque($0).takeUnretainedValue() as String }) {
+ if id == "com.apple.keylayout.ABC" || id == "com.apple.keylayout.US" {
+ return source
+ }
+ }
+ }
+ // 回退到第一个 ASCII 能力的输入源
+ return list.first
+ }
+
+ /// 切换到指定输入源并返回原输入源
+ static func switchToASCII() -> TISInputSource? {
+ let original = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue()
+ guard let asciiSource = findASCIIInputSource() else {
+ return original
+ }
+ TISSelectInputSource(asciiSource)
+ return original
+ }
+
+ /// 恢复到指定输入源
+ static func restore(_ source: TISInputSource?) {
+ guard let source = source else { return }
+ TISSelectInputSource(source)
+ }
+}
diff --git a/Sources/Core/KeyMonitor.swift b/Sources/Core/KeyMonitor.swift
new file mode 100644
index 0000000..99bb0b5
--- /dev/null
+++ b/Sources/Core/KeyMonitor.swift
@@ -0,0 +1,130 @@
+import Cocoa
+import CoreGraphics
+
+/// 全局 Fn / Globe 键监听器
+final class KeyMonitor {
+
+ // Fn 键标志:CGEventFlags.maskSecondaryFn = 0x00800000
+ private static let fnMask: UInt64 = 0x00800000
+
+ private var eventTap: CFMachPort?
+ private var runLoopSource: CFRunLoopSource?
+ private var isFnPressed = false
+
+ var onFnDown: (() -> Void)?
+ var onFnUp: (() -> Void)?
+
+ func start() -> Bool {
+ guard eventTap == nil else { return true }
+
+ guard CGPreflightListenEventAccess() else {
+ CGRequestListenEventAccess()
+ print("[KeyMonitor] ❌ Accessibility permission not granted")
+ return false
+ }
+
+ let eventMask = (1 << CGEventType.flagsChanged.rawValue)
+ | (1 << CGEventType.keyDown.rawValue)
+ | (1 << CGEventType.keyUp.rawValue)
+
+ let callback: CGEventTapCallBack = { (proxy, type, event, refcon) -> Unmanaged? in
+ let monitor = Unmanaged.fromOpaque(refcon!).takeUnretainedValue()
+ return monitor.handleEvent(proxy: proxy, type: type, event: event)
+ }
+
+ // 尝试 HID 级 tap(能捕获被系统拦截的 Fn 键)
+ var tap = CGEvent.tapCreate(
+ tap: .cghidEventTap,
+ place: .headInsertEventTap,
+ options: .defaultTap,
+ eventsOfInterest: CGEventMask(eventMask),
+ callback: callback,
+ userInfo: Unmanaged.passUnretained(self).toOpaque()
+ )
+
+ if tap == nil {
+ // 回退到会话级 tap
+ print("[KeyMonitor] ⚠️ HID tap failed, falling back to session tap")
+ tap = CGEvent.tapCreate(
+ tap: .cgSessionEventTap,
+ place: .headInsertEventTap,
+ options: .defaultTap,
+ eventsOfInterest: CGEventMask(eventMask),
+ callback: callback,
+ userInfo: Unmanaged.passUnretained(self).toOpaque()
+ )
+ }
+
+ guard let tap = tap else {
+ print("[KeyMonitor] ❌ Failed to create event tap")
+ return false
+ }
+
+ eventTap = tap
+ runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0)
+ CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes)
+ CGEvent.tapEnable(tap: tap, enable: true)
+
+ print("[KeyMonitor] ✅ Started")
+ return true
+ }
+
+ func stop() {
+ guard let tap = eventTap else { return }
+ CGEvent.tapEnable(tap: tap, enable: false)
+ if let source = runLoopSource {
+ CFRunLoopRemoveSource(CFRunLoopGetCurrent(), source, .commonModes)
+ }
+ eventTap = nil
+ runLoopSource = nil
+ print("[KeyMonitor] Stopped")
+ }
+
+ private func handleEvent(proxy: CGEventTapProxy, type: CGEventType, event: CGEvent) -> Unmanaged? {
+ let flags = event.flags.rawValue
+ let fnInFlags = (flags & Self.fnMask) != 0
+
+ switch type {
+ case .flagsChanged:
+ if fnInFlags && !isFnPressed {
+ print("[KeyMonitor] ⚡️ Fn DOWN (flags=0x\(String(flags, radix: 16)))")
+ return handleFnDown()
+ }
+ if !fnInFlags && isFnPressed {
+ print("[KeyMonitor] ⚡️ Fn UP (flags=0x\(String(flags, radix: 16)))")
+ return handleFnUp()
+ }
+ return Unmanaged.passUnretained(event)
+
+ case .keyDown, .keyUp:
+ let keyCode = event.getIntegerValueField(.keyboardEventKeycode)
+ if keyCode == 63 && fnInFlags {
+ return handleFnDown()
+ }
+ return Unmanaged.passUnretained(event)
+
+ default:
+ 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
new file mode 100644
index 0000000..49a0c4f
--- /dev/null
+++ b/Sources/Core/LLMRefiner.swift
@@ -0,0 +1,97 @@
+import Foundation
+
+/// 通过 OpenAI 兼容 API 对转录文本进行纠错优化
+final class LLMRefiner {
+
+ private let session: URLSession = {
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 15
+ config.timeoutIntervalForResource = 20
+ return URLSession(configuration: config)
+ }()
+
+ /// 纠错结果
+ struct RefinementResult {
+ let refinedText: 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")
+
+ let systemPrompt = """
+ 你是一个语音识别后处理工具。你唯一的任务是修正转录文本中的明显识别错误。规则:
+ 1. 修正中文同音字/词错误。语音识别经常把技术词汇识别为同音的日常词汇,例如:
+ 「变异」→「编译」、「程序圆」→「程序员」、「配森」→「Python」。
+ 请根据上下文判断修正方向,将同音错误还原为正确的词汇。
+ 2. 修正被错误转录为中文的英文技术术语。
+ 3. 修正极其明显的中文和英文拼写错误(包括错别字、漏字、多字)。
+ 4. 修正明显的乱码或无意义字符序列。
+ 5. 绝不重写、润色、改进或修改任何看起来正确的内容。
+ 6. 如果输入本身正确,请原样返回,不做任何修改。
+ 7. 保留所有标点符号、空格、大小写和格式。
+ 8. 只返回修正后的文本,不要添加任何解释、前缀、后缀或 Markdown 标记。
+ """
+
+ let messages: [[String: String]] = [
+ ["role": "system", "content": systemPrompt],
+ ["role": "user", "content": text]
+ ]
+
+ let body: [String: Any] = [
+ "model": config.llmModel,
+ "messages": messages,
+ "max_tokens": 4096,
+ "thinking": ["type": "disabled"]
+ ]
+
+ urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await session.data(for: urlRequest)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw NSError(domain: "LLMRefiner", code: -1,
+ userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ let body = String(data: data, encoding: .utf8) ?? "Unknown"
+ throw NSError(domain: "LLMRefiner", code: httpResponse.statusCode,
+ userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode): \(body)"])
+ }
+
+ guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
+ 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 {
+ throw NSError(domain: "LLMRefiner", code: -2,
+ userInfo: [NSLocalizedDescriptionKey: "Failed to parse response"])
+ }
+
+ let trimmed = refined.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)
+ }
+
+ /// 测试连接
+ func testConnection(config: Config) async throws -> String {
+ let testText = "测试连接"
+ let result = try await refine(text: testText, config: config)
+ return result.refinedText
+ }
+}
diff --git a/Sources/Core/SpeechRecognizer.swift b/Sources/Core/SpeechRecognizer.swift
new file mode 100644
index 0000000..03799ed
--- /dev/null
+++ b/Sources/Core/SpeechRecognizer.swift
@@ -0,0 +1,133 @@
+import Foundation
+import Speech
+import AVFAudio
+
+/// 流式语音识别,使用 Apple Speech Recognition 框架
+final class SpeechRecognizer: NSObject, SFSpeechRecognizerDelegate {
+
+ private var recognizer: SFSpeechRecognizer?
+ private var request: SFSpeechAudioBufferRecognitionRequest?
+ private var task: SFSpeechRecognitionTask?
+ private let engine = AVAudioEngine()
+ private var isRunning = false
+
+ var onResult: ((String) -> Void)?
+ var onError: ((Error) -> Void)?
+ var onStateChange: ((Bool) -> Void)? // true = 开始识别中
+
+ override init() {
+ super.init()
+ }
+
+ /// 请求权限
+ func requestAuthorization() async -> Bool {
+ let status = await withCheckedContinuation { (continuation: CheckedContinuation) in
+ SFSpeechRecognizer.requestAuthorization { status in
+ continuation.resume(returning: status)
+ }
+ }
+ return status == .authorized
+ }
+
+ /// 使用指定语言启动识别
+ func start(language: String) async -> Bool {
+ guard !isRunning else { return true }
+
+ let authorized = await requestAuthorization()
+ guard authorized else {
+ onError?(NSError(domain: "SpeechRecognizer", code: -1,
+ userInfo: [NSLocalizedDescriptionKey: "Speech recognition not authorized"]))
+ return false
+ }
+
+ guard let recognizer = SFSpeechRecognizer(locale: Locale(identifier: language)),
+ recognizer.isAvailable else {
+ let msg = "Speech recognizer unavailable for \(language)"
+ onError?(NSError(domain: "SpeechRecognizer", code: -2,
+ userInfo: [NSLocalizedDescriptionKey: msg]))
+ return false
+ }
+
+ self.recognizer = recognizer
+ recognizer.delegate = self
+ recognizer.defaultTaskHint = .dictation
+
+ // 创建新的请求
+ request = SFSpeechAudioBufferRecognitionRequest()
+ guard let request = request else { return false }
+ request.shouldReportPartialResults = true
+ request.addsPunctuation = true
+
+ // 获取音频输入
+ let inputNode = engine.inputNode
+ let format = inputNode.outputFormat(forBus: 0)
+ inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
+ self?.request?.append(buffer)
+ }
+
+ do {
+ try engine.start()
+ } catch {
+ onError?(error)
+ return false
+ }
+
+ // 开始识别任务
+ task = recognizer.recognitionTask(with: request) { [weak self] result, error in
+ guard let self = self else { return }
+
+ if let error = error {
+ DispatchQueue.main.async {
+ self.onError?(error)
+ }
+ return
+ }
+
+ if let result = result {
+ // 获取当前最佳转录及未完成的备选
+ let text = result.bestTranscription.formattedString
+ DispatchQueue.main.async {
+ self.onResult?(text)
+ }
+ }
+ }
+
+ isRunning = true
+ onStateChange?(true)
+ print("[Speech] Started recognition for \(language)")
+ return true
+ }
+
+ func stop() -> String? {
+ guard isRunning else { return nil }
+ isRunning = false
+
+ engine.inputNode.removeTap(onBus: 0)
+ engine.stop()
+
+ // 结束识别流
+ request?.endAudio()
+ task?.finish()
+
+ // 结果已在回调中处理
+ request = nil
+ task = nil
+ recognizer = nil
+
+ onStateChange?(false)
+ print("[Speech] Stopped recognition")
+ return nil
+ }
+
+ func cancel() {
+ task?.cancel()
+ _ = stop()
+ }
+
+ // MARK: - SFSpeechRecognizerDelegate
+
+ func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer,
+ availabilityDidChange available: Bool) {
+ print("[Speech] Availability changed: \(available)")
+ }
+}
diff --git a/Sources/Core/TextInjector.swift b/Sources/Core/TextInjector.swift
new file mode 100644
index 0000000..a09a2e5
--- /dev/null
+++ b/Sources/Core/TextInjector.swift
@@ -0,0 +1,105 @@
+import Cocoa
+import Carbon
+
+/// 通过剪贴板备份 → 写入 → Cmd+V 粘贴 → 恢复剪贴板的方式注入文本
+struct TextInjector {
+
+ /// 剪贴板备份结构
+ private struct PasteboardBackup {
+ let items: [[String: Data]]
+ }
+
+ /// 将文本注入当前焦点输入框(异步完成剪贴板恢复)
+ @discardableResult
+ static func inject(_ text: String) -> Bool {
+ guard !text.isEmpty else { return false }
+
+ // 备份
+ let backup = backupPasteboard()
+
+ // 检测输入法
+ let isCJK = InputSourceManager.isCJKInputSource()
+ let originalSource: TISInputSource? = isCJK ? InputSourceManager.switchToASCII() : nil
+
+ if isCJK {
+ Thread.sleep(forTimeInterval: 0.05)
+ }
+
+ // 写入目标文本
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(text, forType: .string)
+
+ // 模拟 Cmd+V(必须在主线程 run loop 中处理)
+ simulatePaste()
+
+ // 恢复输入法(不阻塞主线程)
+ if isCJK {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ InputSourceManager.restore(originalSource)
+ }
+ }
+
+ // 延迟恢复剪贴板,确保粘贴事件已被目标应用处理
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ restorePasteboard(backup)
+ }
+
+ print("[Injector] Injected \"\(text.prefix(50))...\" (\(text.count) chars)")
+ return true
+ }
+
+ // MARK: - 剪贴板备份/恢复
+
+ private static func backupPasteboard() -> PasteboardBackup {
+ let pb = NSPasteboard.general
+ guard let items = pb.pasteboardItems, !items.isEmpty else {
+ var fallback: [[String: Data]] = []
+ if let str = pb.string(forType: .string),
+ let data = str.data(using: .utf8) {
+ fallback = [["public.utf8-plain-text": data]]
+ }
+ return PasteboardBackup(items: fallback)
+ }
+
+ let backedItems: [[String: Data]] = items.compactMap { item in
+ var typeData: [String: Data] = [:]
+ for type in item.types {
+ if let data = item.data(forType: type) {
+ typeData[type.rawValue] = data
+ }
+ }
+ return typeData.isEmpty ? nil : typeData
+ }
+
+ return PasteboardBackup(items: backedItems)
+ }
+
+ private static func restorePasteboard(_ backup: PasteboardBackup) {
+ let pb = NSPasteboard.general
+ pb.clearContents()
+
+ for typeData in backup.items {
+ let newItem = NSPasteboardItem()
+ for (type, data) in typeData {
+ newItem.setData(data, forType: NSPasteboard.PasteboardType(type))
+ }
+ pb.writeObjects([newItem])
+ }
+ }
+
+ // MARK: - 模拟粘贴
+
+ private static func simulatePaste() {
+ let source = CGEventSource(stateID: .combinedSessionState)
+
+ let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
+ keyDown?.flags = .maskCommand
+ keyDown?.post(tap: .cghidEventTap)
+
+ usleep(10_000)
+
+ let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
+ keyUp?.flags = .maskCommand
+ keyUp?.post(tap: .cghidEventTap)
+ }
+}
diff --git a/Sources/Resources/Info.plist b/Sources/Resources/Info.plist
new file mode 100644
index 0000000..7fcde88
--- /dev/null
+++ b/Sources/Resources/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ zh_CN
+ CFBundleExecutable
+ VoiceInput
+ CFBundleIdentifier
+ com.voiceinput.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ VoiceInput
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundleVersion
+ 1
+ LSMinimumSystemVersion
+ 14.0
+ LSUIElement
+
+ NSMicrophoneUsageDescription
+ Voice Input needs microphone access to record your speech for transcription.
+ NSSpeechRecognitionUsageDescription
+ Voice Input needs speech recognition access to transcribe your voice into text.
+
+
diff --git a/Sources/UI/RecordingPanel.swift b/Sources/UI/RecordingPanel.swift
new file mode 100644
index 0000000..d8a5c56
--- /dev/null
+++ b/Sources/UI/RecordingPanel.swift
@@ -0,0 +1,254 @@
+import Cocoa
+
+/// 录音期间屏幕底部居中的胶囊形浮动窗口
+final class RecordingPanel: NSPanel {
+
+ // MARK: - 子视图
+ let waveformView: WaveformView
+ private let textLabel: NSTextField
+ private let containerStack: NSStackView
+
+ // 文本宽度约束
+ private var textWidthConstraint: NSLayoutConstraint?
+
+ // 窗口宽度常量
+ private let minPanelWidth: CGFloat = 180
+ private let maxPanelWidth: CGFloat = 680
+ private let panelHeight: CGFloat = 56
+ private let cornerRadius: CGFloat = 28
+ private let waveformSize = NSSize(width: 44, height: 32)
+ private let marginAboveDock: CGFloat = 12
+
+ init() {
+ // Waveform
+ waveformView = WaveformView(frame: NSRect(origin: .zero, size: waveformSize))
+ waveformView.translatesAutoresizingMaskIntoConstraints = false
+ waveformView.wantsLayer = true
+
+ // Label
+ textLabel = NSTextField(labelWithString: "")
+ textLabel.translatesAutoresizingMaskIntoConstraints = false
+ textLabel.font = .systemFont(ofSize: 16, weight: .medium)
+ textLabel.textColor = .white
+ textLabel.alignment = .left
+ textLabel.lineBreakMode = .byTruncatingTail
+ textLabel.maximumNumberOfLines = 1
+ textLabel.backgroundColor = .clear
+ textLabel.isBezeled = false
+ textLabel.drawsBackground = false
+
+ // Stack
+ containerStack = NSStackView(views: [waveformView, textLabel])
+ containerStack.translatesAutoresizingMaskIntoConstraints = false
+ containerStack.orientation = .horizontal
+ containerStack.alignment = .centerY
+ containerStack.spacing = 12
+ containerStack.edgeInsets = NSEdgeInsets(top: 0, left: 16, bottom: 0, right: 16)
+
+ // Panel 配置
+ super.init(
+ contentRect: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight),
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+
+ configurePanel()
+ configureVisualEffect()
+ setupConstraints()
+ }
+
+ private func configurePanel() {
+ isFloatingPanel = true
+ becomesKeyOnlyIfNeeded = true
+ isOpaque = false
+ hasShadow = false
+ level = .floating
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ ignoresMouseEvents = false
+ titleVisibility = .hidden
+ titlebarAppearsTransparent = true
+ isMovableByWindowBackground = false
+
+ // 彻底去除边框
+ backgroundColor = .clear
+ isOpaque = false
+ }
+
+ private func configureVisualEffect() {
+ let visualEffect = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: minPanelWidth, height: panelHeight))
+ visualEffect.material = .hudWindow
+ visualEffect.blendingMode = .behindWindow
+ visualEffect.state = .active
+ visualEffect.wantsLayer = true
+ visualEffect.layer?.cornerRadius = cornerRadius
+ visualEffect.layer?.masksToBounds = true
+ visualEffect.layer?.borderWidth = 0
+ visualEffect.layer?.borderColor = NSColor.clear.cgColor
+ visualEffect.autoresizingMask = [.width, .height]
+
+ // 替换 contentView
+ contentView = visualEffect
+ visualEffect.addSubview(containerStack)
+ }
+
+ private func setupConstraints() {
+ guard let content = contentView else { return }
+
+ NSLayoutConstraint.activate([
+ // Waveform 固定尺寸
+ waveformView.widthAnchor.constraint(equalToConstant: waveformSize.width),
+ waveformView.heightAnchor.constraint(equalToConstant: waveformSize.height),
+
+ // Container 填充
+ containerStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+ containerStack.centerYAnchor.constraint(equalTo: content.centerYAnchor),
+ containerStack.heightAnchor.constraint(equalToConstant: panelHeight)
+ ])
+
+ // 文本弹性宽度
+ textWidthConstraint = textLabel.widthAnchor.constraint(equalToConstant: 160)
+ textWidthConstraint?.isActive = true
+ }
+
+ /// 更新转录文本
+ func updateText(_ text: String) {
+ textLabel.stringValue = text
+
+ // 计算所需宽度
+ let maxTextWidth = maxPanelWidth - waveformSize.width - 40 // 40 = spacing + insets
+ let attributes: [NSAttributedString.Key: Any] = [.font: textLabel.font!]
+ let textSize = (text as NSString).size(withAttributes: attributes)
+ let requiredTextWidth = min(max(textSize.width + 10, 160), maxTextWidth)
+ let targetPanelWidth = requiredTextWidth + waveformSize.width + 44
+
+ // 动画更新宽度
+ NSAnimationContext.runAnimationGroup { ctx in
+ ctx.duration = 0.25
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ ctx.allowsImplicitAnimation = true
+
+ textWidthConstraint?.animator().constant = requiredTextWidth
+
+ let frame = currentScreenFrame()
+ let y = bottomY(for: frame)
+ let newFrame = NSRect(
+ x: (frame.width - targetPanelWidth) / 2,
+ y: y,
+ width: targetPanelWidth,
+ height: panelHeight
+ )
+ animator().setFrame(newFrame, display: true)
+ }
+ }
+
+ /// 显示面板(弹簧动画)
+ func showAnimated() {
+ let frame = currentScreenFrame()
+ let y = bottomY(for: frame)
+
+ let initialFrame = NSRect(
+ x: (frame.width - minPanelWidth) / 2,
+ y: y,
+ width: minPanelWidth,
+ height: panelHeight
+ )
+
+ // 初始状态:缩小且透明
+ setFrame(initialFrame, display: false)
+ alphaValue = 0
+
+ // 出场
+ makeKeyAndOrderFront(nil)
+
+ // 弹簧动画
+ NSAnimationContext.runAnimationGroup { ctx in
+ ctx.duration = 0.35
+ ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.0, 0.5, 0.3, 1.5) // 弹簧曲线
+ animator().alphaValue = 1.0
+ }
+ // 弹簧效果通过 CATransaction 模拟
+ let spring = CASpringAnimation(keyPath: "transform.scale")
+ spring.fromValue = 0.85
+ spring.toValue = 1.0
+ spring.duration = 0.35
+ spring.damping = 12
+ spring.initialVelocity = 0
+ spring.fillMode = .backwards
+ contentView?.layer?.add(spring, forKey: "showSpring")
+ }
+
+ /// 隐藏面板(缩放动画)
+ func hideAnimated(completion: (() -> Void)? = nil) {
+ NSAnimationContext.runAnimationGroup({ ctx in
+ ctx.duration = 0.22
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeIn)
+ animator().alphaValue = 0
+
+ // 缩放
+ let scale = CABasicAnimation(keyPath: "transform.scale")
+ scale.fromValue = 1.0
+ scale.toValue = 0.9
+ scale.duration = 0.22
+ scale.timingFunction = CAMediaTimingFunction(name: .easeIn)
+ scale.fillMode = .forwards
+ scale.isRemovedOnCompletion = false
+ contentView?.layer?.add(scale, forKey: "hideScale")
+
+ }, completionHandler: {
+ self.orderOut(nil)
+ self.contentView?.layer?.removeAnimation(forKey: "hideScale")
+ self.textLabel.stringValue = ""
+ self.textWidthConstraint?.animator().constant = 160
+ completion?()
+ })
+ }
+
+ /// 显示 Refining 状态(弹窗自适应缩窄)
+ func showRefining() {
+ textLabel.stringValue = "Refining..."
+ let refiningTextWidth: CGFloat = 120
+ let targetWidth: CGFloat = refiningTextWidth + waveformSize.width + 44
+
+ NSAnimationContext.runAnimationGroup { ctx in
+ ctx.duration = 0.2
+ ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ ctx.allowsImplicitAnimation = true
+
+ textWidthConstraint?.animator().constant = refiningTextWidth
+
+ 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.3)
+ }
+
+ // 重写:允许在非 active 状态响应
+ override var canBecomeKey: Bool { true }
+ override var canBecomeMain: Bool { false }
+
+ // MARK: - 屏幕坐标
+
+ private func currentScreenFrame() -> NSRect {
+ // 优先使用鼠标所在屏幕,否则主屏幕
+ let mouseLocation = NSEvent.mouseLocation
+ for screen in NSScreen.screens {
+ if screen.frame.contains(mouseLocation) {
+ return screen.visibleFrame
+ }
+ }
+ return NSScreen.main?.visibleFrame ?? .zero
+ }
+
+ private func bottomY(for screenFrame: NSRect) -> CGFloat {
+ screenFrame.minY + marginAboveDock
+ }
+}
diff --git a/Sources/UI/SettingsWindow.swift b/Sources/UI/SettingsWindow.swift
new file mode 100644
index 0000000..8cc5557
--- /dev/null
+++ b/Sources/UI/SettingsWindow.swift
@@ -0,0 +1,207 @@
+import Cocoa
+
+/// LLM 设置窗口
+final class SettingsWindow: NSWindow {
+
+ private let baseURLField: NSTextField
+ private let apiKeyField: NSSecureTextField
+ private let modelField: NSTextField
+ private let testButton: NSButton
+ private let saveButton: NSButton
+ private let statusLabel: NSTextField
+
+ init() {
+ // 表单字段
+ baseURLField = NSTextField(frame: .zero)
+ apiKeyField = NSSecureTextField(frame: .zero)
+ modelField = NSTextField(frame: .zero)
+ testButton = NSButton(title: "Test", target: nil, action: nil)
+ saveButton = NSButton(title: "Save", target: nil, action: nil)
+ statusLabel = NSTextField(labelWithString: "")
+
+ let windowRect = NSRect(x: 0, y: 0, width: 420, height: 280)
+ super.init(
+ contentRect: windowRect,
+ styleMask: [.titled, .closable, .miniaturizable],
+ backing: .buffered,
+ defer: false
+ )
+
+ configureWindow()
+ buildUI()
+ loadCurrentConfig()
+ }
+
+ private func configureWindow() {
+ title = "LLM Settings"
+ isReleasedWhenClosed = false
+ center()
+ }
+
+ 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)
+ label.textColor = .secondaryLabelColor
+ label.alignment = .right
+ label.translatesAutoresizingMaskIntoConstraints = false
+ 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
+ baseURLField.translatesAutoresizingMaskIntoConstraints = false
+
+ apiKeyField.placeholderString = "sk-..."
+ apiKeyField.font = .systemFont(ofSize: 13)
+ apiKeyField.bezelStyle = .roundedBezel
+ apiKeyField.translatesAutoresizingMaskIntoConstraints = false
+
+ modelField.placeholderString = "gpt-3.5-turbo"
+ modelField.font = .systemFont(ofSize: 13)
+ modelField.bezelStyle = .roundedBezel
+ modelField.translatesAutoresizingMaskIntoConstraints = false
+
+ testButton.bezelStyle = .rounded
+ testButton.translatesAutoresizingMaskIntoConstraints = false
+ testButton.target = self
+ testButton.action = #selector(testConnection)
+
+ saveButton.bezelStyle = .rounded
+ saveButton.translatesAutoresizingMaskIntoConstraints = false
+ saveButton.keyEquivalent = "\r"
+ saveButton.target = self
+ saveButton.action = #selector(saveConfig)
+
+ statusLabel.font = .systemFont(ofSize: 11)
+ statusLabel.textColor = .secondaryLabelColor
+ statusLabel.alignment = .center
+ statusLabel.maximumNumberOfLines = 2
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ // 布局
+ let grid = NSGridView(views: [
+ [baseURLLabel, baseURLField],
+ [apiKeyLabel, apiKeyField],
+ [modelLabel, modelField]
+ ])
+ grid.translatesAutoresizingMaskIntoConstraints = false
+ grid.column(at: 0).xPlacement = .trailing
+ grid.column(at: 1).width = 260
+ grid.rowSpacing = 12
+ grid.columnSpacing = 8
+
+ let buttonStack = NSStackView(views: [testButton, saveButton])
+ buttonStack.translatesAutoresizingMaskIntoConstraints = false
+ buttonStack.orientation = .horizontal
+ buttonStack.spacing = 12
+ buttonStack.distribution = .fillEqually
+
+ content.addSubview(grid)
+ content.addSubview(buttonStack)
+ content.addSubview(statusLabel)
+
+ NSLayoutConstraint.activate([
+ grid.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
+ grid.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
+ grid.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
+
+ buttonStack.topAnchor.constraint(equalTo: grid.bottomAnchor, constant: 20),
+ buttonStack.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+ buttonStack.widthAnchor.constraint(equalToConstant: 220),
+
+ statusLabel.topAnchor.constraint(equalTo: buttonStack.bottomAnchor, constant: 12),
+ statusLabel.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+ statusLabel.widthAnchor.constraint(equalToConstant: 380)
+ ])
+ }
+
+ private func loadCurrentConfig() {
+ baseURLField.stringValue = Config.shared.llmBaseURL
+ apiKeyField.stringValue = Config.shared.llmAPIKey
+ modelField.stringValue = Config.shared.llmModel
+ }
+
+ @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
+
+ guard !url.isEmpty, !key.isEmpty, !model.isEmpty else {
+ statusLabel.stringValue = "Please fill all fields"
+ statusLabel.textColor = .systemRed
+ testButton.isEnabled = true
+ saveButton.isEnabled = true
+ return
+ }
+
+ 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
+
+ do {
+ let result = try await refiner.testConnection(config: Config.shared)
+ statusLabel.stringValue = "✅ Connected! Response: \(result)"
+ statusLabel.textColor = .systemGreen
+ } catch {
+ statusLabel.stringValue = "❌ Error: \(error.localizedDescription)"
+ statusLabel.textColor = .systemRed
+ }
+
+ // 恢复
+ Config.shared.llmBaseURL = original.0
+ Config.shared.llmAPIKey = original.1
+ Config.shared.llmModel = original.2
+
+ testButton.isEnabled = true
+ saveButton.isEnabled = true
+ }
+ }
+
+ @objc private func saveConfig() {
+ let url = baseURLField.stringValue.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ let key = apiKeyField.stringValue
+ let model = modelField.stringValue
+
+ Config.shared.llmBaseURL = url
+ Config.shared.llmAPIKey = key
+ Config.shared.llmModel = model
+
+ statusLabel.stringValue = "✅ Saved"
+ statusLabel.textColor = .systemGreen
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
+ self?.statusLabel.stringValue = ""
+ }
+ }
+}
diff --git a/Sources/UI/WaveformView.swift b/Sources/UI/WaveformView.swift
new file mode 100644
index 0000000..22ce643
--- /dev/null
+++ b/Sources/UI/WaveformView.swift
@@ -0,0 +1,100 @@
+import Cocoa
+
+/// 5 个球形波动画,由实时音频 RMS 电平驱动
+final class WaveformView: NSView {
+
+ private let weights: [Float] = [0.5, 0.8, 1.0, 0.75, 0.55]
+ private var dots: [NSView] = []
+ private var smoothedLevels: [Float] = [0, 0, 0, 0, 0]
+ private var decayTimer: Timer?
+ private var rawLevel: Float = 0
+
+ private let dotSize: CGFloat = 4
+ private let maxExtra: CGFloat = 28
+ private let spacing: CGFloat = 6
+ private let silenceThreshold: Float = 0.02
+
+ override init(frame: NSRect) {
+ super.init(frame: frame)
+ setupDots()
+ wantsLayer = true
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func setupDots() {
+ dots.forEach { $0.removeFromSuperview() }
+ dots.removeAll()
+
+ let totalWidth = CGFloat(weights.count) * dotSize + CGFloat(weights.count - 1) * spacing
+ let startX = (bounds.width - totalWidth) / 2
+
+ for (index, _) in weights.enumerated() {
+ let x = startX + CGFloat(index) * (dotSize + spacing)
+ let dot = NSView(frame: NSRect(x: x, y: 0, width: dotSize, height: dotSize))
+ dot.wantsLayer = true
+ dot.layer?.cornerRadius = dotSize / 2
+ dot.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.9).cgColor
+ addSubview(dot)
+ dots.append(dot)
+ }
+
+ decayTimer?.invalidate()
+ decayTimer = Timer.scheduledTimer(withTimeInterval: 0.04, repeats: true) { [weak self] _ in
+ self?.updateDecay()
+ }
+ }
+
+ func updateLevel(_ level: Float) {
+ rawLevel = level
+ for i in 0.. smoothedLevels[i] {
+ smoothedLevels[i] = smoothedLevels[i] * 0.55 + target * 0.45
+ }
+ }
+ applyLevels()
+ }
+
+ private func updateDecay() {
+ for i in 0.. "$CONTENTS_DIR/PkgInfo"
+
+echo "✅ App bundle created: $BUNDLE_DIR"