- 侧边栏 4 → 6 项:首页 / 历史记录 / 词汇表 / 风格 / 帮助中心 / 设置 - 词典 → 词汇表,整页改 chip 流式布局:phrase + hitCount,hover ✕ 删除, 批量输入(每行一个),重置统计 / 清除全部 - DictionaryEntry 加 hitCount + 兼容旧 dictionary.json 解码 - DictationCoordinator 在润色后扫描最终文本递增命中数; 删除 learnTerms 自动学词路径 - 润色模式 → 风格 (StyleTab):4 张模式卡 + 启用开关; 关闭时跳过 Ark 直接插入原文 (UserPreferences.polishEnabled) - 帮助中心:快速上手 4 步、快捷键速查、FAQ、GitHub 外链 - 设置 Tab 合并 Volc ASR + Ark 凭据、录音、授权、隐私、关于 + 检查更新 - 胶囊:NSPanel 220×96, hasShadow=false;macOS 26 分支去掉 SwiftUI 阴影 → 解决"红绿灯外圈黑边" - 侧边栏视觉:strokeBorder 0.08 → 0.04、阴影 radius 16 → 6 → 解决"灰色边栏 + 黑色卡顿" - 首页底部新增今日概览 + 风格预览
567 lines
20 KiB
Swift
567 lines
20 KiB
Swift
import AppKit
|
||
import Foundation
|
||
import OpenLessCore
|
||
import OpenLessHotkey
|
||
import OpenLessUI
|
||
import OpenLessRecorder
|
||
import OpenLessASR
|
||
import OpenLessPolish
|
||
import OpenLessInsertion
|
||
import OpenLessPersistence
|
||
|
||
@MainActor
|
||
final class DictationCoordinator {
|
||
private enum SessionPhase {
|
||
case idle
|
||
case starting
|
||
case listening
|
||
case processing
|
||
}
|
||
|
||
private let hotkey: HotkeyMonitor
|
||
private let recorder = Recorder()
|
||
private let inserter = TextInserter()
|
||
private let history = HistoryStore()
|
||
private let dictionary = DictionaryStore()
|
||
private let capsule = CapsuleWindowController()
|
||
private let settings = SettingsWindowController()
|
||
|
||
weak var menuBar: MenuBarController?
|
||
|
||
/// Toggle 状态:idle → 开始;listening → 结束;starting/processing 阶段忽略重复触发。
|
||
private var sessionPhase: SessionPhase = .idle
|
||
private var asr: VolcengineStreamingASR?
|
||
private var audioConsumer: BufferingAudioConsumer?
|
||
private var sessionStartedAt: Date = Date()
|
||
/// hold 模式下,Esc 取消后下一次 .released 应被忽略(否则会再次触发结束流程)。
|
||
private var suppressNextRelease = false
|
||
|
||
/// 启动时一次性读 Keychain 缓存的凭据快照;会话热路径只读这里,
|
||
/// 不再每次都打 SecItemCopyMatching 触发钥匙串弹窗。
|
||
/// 设置页保存后通过 .openLessCredentialsChanged 通知刷新。
|
||
private var credentials: CredentialsSnapshot = CredentialsSnapshot(
|
||
volcengineAppKey: nil,
|
||
volcengineAccessKey: nil,
|
||
volcengineResourceId: nil,
|
||
arkApiKey: nil,
|
||
arkModelId: nil,
|
||
arkEndpoint: nil
|
||
)
|
||
|
||
init() {
|
||
self.hotkey = HotkeyMonitor()
|
||
}
|
||
|
||
func bootstrap() {
|
||
Log.write("=== OpenLess 启动 ===")
|
||
Log.write("日志: \(Log.fileURL.path)")
|
||
|
||
if !AccessibilityPermission.isGranted() {
|
||
Log.write("辅助功能权限未授予;请到「系统设置 → 隐私与安全 → 辅助功能」勾选 OpenLess")
|
||
_ = AccessibilityPermission.request()
|
||
}
|
||
|
||
capsule.onCancel = { [weak self] in self?.handleCancel() }
|
||
capsule.onConfirm = { [weak self] in self?.handleToggle() }
|
||
|
||
// 启动时集中读一次:rebuild 后这里会触发若干钥匙串弹窗,但用户讲话期间不再弹。
|
||
refreshCredentials()
|
||
observeCredentialsChanged()
|
||
|
||
startHotkey()
|
||
observeHotkeyEvents()
|
||
observeBindingChanged()
|
||
}
|
||
|
||
private func refreshCredentials() {
|
||
credentials = CredentialsVault.shared.snapshot()
|
||
Log.write("[creds] 凭据快照已刷新(volc=\(credentials.volcengineAppKey?.isEmpty == false), ark=\(credentials.arkApiKey?.isEmpty == false))")
|
||
}
|
||
|
||
private func observeCredentialsChanged() {
|
||
NotificationCenter.default.addObserver(
|
||
forName: .openLessCredentialsChanged,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
self?.refreshCredentials()
|
||
}
|
||
}
|
||
}
|
||
|
||
func openHome() {
|
||
settings.show(tab: .home)
|
||
}
|
||
|
||
func openHistory() {
|
||
settings.show(tab: .history)
|
||
}
|
||
|
||
func openDictionary() {
|
||
settings.show(tab: .dictionary)
|
||
}
|
||
|
||
func openSettings() {
|
||
settings.show(tab: .settings)
|
||
}
|
||
|
||
func toggleDictationFromMenu() {
|
||
handleToggle()
|
||
}
|
||
|
||
private func startHotkey() {
|
||
let binding = HotkeyBinding(trigger: UserPreferences.shared.hotkeyTrigger)
|
||
do {
|
||
try hotkey.start(binding: binding)
|
||
Log.write("Hotkey 已启动,trigger=\(binding.trigger.displayName)")
|
||
} catch {
|
||
Log.write("Hotkey 启动失败: \(error)")
|
||
}
|
||
}
|
||
|
||
private func observeBindingChanged() {
|
||
NotificationCenter.default.addObserver(
|
||
forName: .openLessHotkeyChanged,
|
||
object: nil,
|
||
queue: .main
|
||
) { [weak self] _ in
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
let binding = HotkeyBinding(trigger: UserPreferences.shared.hotkeyTrigger)
|
||
self.hotkey.updateBinding(binding)
|
||
Log.write("Hotkey trigger → \(binding.trigger.displayName)")
|
||
}
|
||
}
|
||
}
|
||
|
||
private func observeHotkeyEvents() {
|
||
Task { @MainActor [weak self] in
|
||
guard let self else { return }
|
||
for await event in self.hotkey.events {
|
||
switch event {
|
||
case .pressed:
|
||
self.handlePressed()
|
||
case .released:
|
||
self.handleReleased()
|
||
case .cancelled:
|
||
self.handleCancel()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Toggle / Hold 状态机
|
||
|
||
private func handlePressed() {
|
||
switch UserPreferences.shared.hotkeyMode {
|
||
case .toggle:
|
||
handleToggle()
|
||
case .hold:
|
||
handleHoldStart()
|
||
}
|
||
}
|
||
|
||
private func handleReleased() {
|
||
guard UserPreferences.shared.hotkeyMode == .hold else { return }
|
||
if suppressNextRelease {
|
||
suppressNextRelease = false
|
||
return
|
||
}
|
||
switch sessionPhase {
|
||
case .listening:
|
||
sessionPhase = .processing
|
||
Task { await endSession() }
|
||
case .starting:
|
||
// 用户没等到 ASR 连上就松手 — 当作取消,不发送任何已采集音频。
|
||
Log.write("[session] hold: starting 阶段松手,取消")
|
||
handleCancel()
|
||
case .idle, .processing:
|
||
return
|
||
}
|
||
}
|
||
|
||
private func handleHoldStart() {
|
||
switch sessionPhase {
|
||
case .idle:
|
||
sessionPhase = .starting
|
||
Task { await beginSession() }
|
||
case .starting, .listening, .processing:
|
||
// hold 模式下重复 .pressed 通常来自系统自动重发;忽略即可。
|
||
return
|
||
}
|
||
}
|
||
|
||
private func handleToggle() {
|
||
switch sessionPhase {
|
||
case .idle:
|
||
sessionPhase = .starting
|
||
Task { await beginSession() }
|
||
case .listening:
|
||
sessionPhase = .processing
|
||
Task { await endSession() }
|
||
case .starting:
|
||
Log.write("[session] 正在启动,忽略重复触发")
|
||
case .processing:
|
||
Log.write("[session] 正在思考中,忽略重复提交")
|
||
}
|
||
}
|
||
|
||
private func handleCancel() {
|
||
guard sessionPhase == .starting || sessionPhase == .listening else { return }
|
||
Log.write("用户取消")
|
||
sessionPhase = .idle
|
||
asr?.cancel()
|
||
asr = nil
|
||
recorder.stop()
|
||
audioConsumer?.clear()
|
||
audioConsumer = nil
|
||
// hold 模式:如果用户还按着键,松手时会再来一次 .released —— 屏蔽掉,避免再次触发结束。
|
||
if UserPreferences.shared.hotkeyMode == .hold {
|
||
suppressNextRelease = true
|
||
}
|
||
capsule.update(state: .cancelled)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
|
||
self?.capsule.update(state: .hidden)
|
||
}
|
||
}
|
||
|
||
private func beginSession() async {
|
||
guard sessionPhase == .starting else { return }
|
||
sessionStartedAt = Date()
|
||
capsule.update(state: .listening, level: 0)
|
||
Log.write("[session] 开始")
|
||
|
||
let audioConsumer = BufferingAudioConsumer()
|
||
self.audioConsumer = audioConsumer
|
||
|
||
do {
|
||
Log.write("[recorder] microphonePermission=\(MicrophonePermission.statusDescription)")
|
||
if !MicrophonePermission.isGranted() {
|
||
NSApp.activate(ignoringOtherApps: true)
|
||
}
|
||
try await recorder.start(
|
||
consumer: audioConsumer,
|
||
levelHandler: { [weak self] level in
|
||
Task { @MainActor [weak self] in
|
||
guard let self,
|
||
self.sessionPhase == .starting || self.sessionPhase == .listening else {
|
||
return
|
||
}
|
||
self.capsule.update(state: .listening, level: level)
|
||
}
|
||
},
|
||
logger: { msg in Log.write(msg) }
|
||
)
|
||
Log.write("[recorder] 麦克风已启动")
|
||
} catch {
|
||
Log.write("[recorder] 启动失败: \(error)")
|
||
await failSession(reason: "录音启动失败")
|
||
return
|
||
}
|
||
guard sessionPhase == .starting else { return }
|
||
|
||
guard let credentials = loadVolcengineCredentials() else {
|
||
Log.write("缺少火山引擎凭据;麦克风已启动,结束后走 mock 模式")
|
||
sessionPhase = .listening
|
||
return
|
||
}
|
||
|
||
let dictionaryEntries = dictionary.enabledEntries()
|
||
let asr = VolcengineStreamingASR(
|
||
credentials: credentials,
|
||
dictionaryEntries: dictionaryEntries,
|
||
logger: { msg in Log.write(msg) }
|
||
)
|
||
self.asr = asr
|
||
|
||
do {
|
||
try await asr.openSession()
|
||
Log.write("[asr] WebSocket 已连接")
|
||
guard sessionPhase == .starting else {
|
||
asr.cancel()
|
||
return
|
||
}
|
||
audioConsumer.attach(asr)
|
||
Log.write("[asr] 音频已接入 ASR")
|
||
sessionPhase = .listening
|
||
} catch {
|
||
Log.write("[asr] 连接失败: \(error)")
|
||
await failSession(reason: "ASR 连接失败")
|
||
return
|
||
}
|
||
}
|
||
|
||
private func endSession() async {
|
||
guard sessionPhase == .processing else { return }
|
||
defer {
|
||
if sessionPhase == .processing {
|
||
sessionPhase = .idle
|
||
}
|
||
}
|
||
// 末尾 padding:用户按下结束的瞬间停止采样会丢掉
|
||
// (1) AVAudioConverter resampler 内部 ~50ms 滤波器尾巴
|
||
// (2) 人脑"预测松手"——最后一两个字的发音常常压在按键时刻之后
|
||
// keep-record 250ms 让这些音频继续进入 recorder → ASR。
|
||
capsule.update(state: .processing)
|
||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||
recorder.stop()
|
||
audioConsumer?.clear()
|
||
audioConsumer = nil
|
||
Log.write("[session] 录音停止(含 250ms 末尾 padding),等待 ASR 终态")
|
||
|
||
guard let asr = self.asr else {
|
||
// mock pipeline:无凭据时给一段提示
|
||
await runMockPipeline()
|
||
return
|
||
}
|
||
|
||
do {
|
||
try await asr.sendLastFrame()
|
||
let raw = try await asr.awaitFinalResult()
|
||
Log.write("[asr] final: \(raw.text)")
|
||
self.asr = nil
|
||
let trimmed = raw.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty {
|
||
Log.write("[asr] 终态文本为空 — 跳过润色与插入")
|
||
await failSession(reason: "没识别到内容")
|
||
return
|
||
}
|
||
await polishAndInsert(
|
||
raw: RawTranscript(text: trimmed, durationMs: raw.durationMs),
|
||
dictionaryEntries: dictionary.enabledEntries()
|
||
)
|
||
} catch {
|
||
Log.write("[asr] final 失败: \(error)")
|
||
self.asr = nil
|
||
await failSession(reason: "识别失败")
|
||
}
|
||
}
|
||
|
||
/// 润色环节实际发生了什么。决定胶囊提示色调和历史记录的真实 mode。
|
||
private enum PolishOutcome {
|
||
case ok // 真润色完成
|
||
case skippedNoCredentials // 没填 Ark,直接跳过
|
||
case failed(String) // 调到了,但报错;error 文本仅作日志
|
||
|
||
var logTag: String {
|
||
switch self {
|
||
case .ok: return "ok"
|
||
case .skippedNoCredentials: return "skip-no-creds"
|
||
case .failed(let msg): return "failed(\(msg.prefix(120)))"
|
||
}
|
||
}
|
||
}
|
||
|
||
private func polishAndInsert(
|
||
raw: RawTranscript,
|
||
originalRawText: String? = nil,
|
||
dictionaryEntries: [DictionaryEntry] = []
|
||
) async {
|
||
let mode = UserPreferences.shared.polishMode
|
||
let savedRaw = originalRawText ?? raw.text
|
||
|
||
// 风格关闭时直接插入原文,不调 Ark。
|
||
guard UserPreferences.shared.polishEnabled else {
|
||
Log.write("[polish] 风格已关闭;跳过润色,插入 raw")
|
||
await insertText(
|
||
text: raw.text,
|
||
raw: savedRaw,
|
||
mode: mode,
|
||
durationMs: raw.durationMs,
|
||
dictionaryEntryCount: dictionaryEntries.count,
|
||
polishOutcome: .skippedNoCredentials
|
||
)
|
||
return
|
||
}
|
||
|
||
guard let arkCreds = loadArkCredentials() else {
|
||
Log.write("[polish] 缺少 Ark 凭据;跳过润色,插入 raw")
|
||
await insertText(
|
||
text: raw.text,
|
||
raw: savedRaw,
|
||
mode: mode,
|
||
durationMs: raw.durationMs,
|
||
dictionaryEntryCount: dictionaryEntries.count,
|
||
polishOutcome: .skippedNoCredentials
|
||
)
|
||
return
|
||
}
|
||
|
||
Log.write("[polish] 调用 endpoint=\(arkCreds.endpoint.absoluteString) model=\(arkCreds.modelId)")
|
||
let polish = DoubaoPolishClient(
|
||
credentials: arkCreds,
|
||
logger: { msg in Log.write(msg) }
|
||
)
|
||
do {
|
||
let final = try await polish.polish(rawTranscript: raw, mode: mode, dictionaryEntries: dictionaryEntries)
|
||
Log.write("[polish] mode=\(mode.rawValue) → \(final.text)")
|
||
await insertText(
|
||
text: final.text,
|
||
raw: savedRaw,
|
||
mode: mode,
|
||
durationMs: raw.durationMs,
|
||
dictionaryEntryCount: dictionaryEntries.count,
|
||
polishOutcome: .ok
|
||
)
|
||
} catch {
|
||
Log.write("[polish] 失败: \(error);插入 raw")
|
||
await insertText(
|
||
text: raw.text,
|
||
raw: savedRaw,
|
||
mode: mode,
|
||
durationMs: raw.durationMs,
|
||
dictionaryEntryCount: dictionaryEntries.count,
|
||
polishOutcome: .failed(String(describing: error))
|
||
)
|
||
}
|
||
}
|
||
|
||
private func insertText(
|
||
text: String,
|
||
raw: String,
|
||
mode: PolishMode,
|
||
durationMs: Int?,
|
||
dictionaryEntryCount: Int,
|
||
polishOutcome: PolishOutcome = .ok
|
||
) async {
|
||
let result = await inserter.insert(text)
|
||
let frontApp = NSWorkspace.shared.frontmostApplication
|
||
// 命中次数:润色后的最终文本里出现的启用词条 +1,词汇表里展示用。
|
||
let hits = dictionary.incrementHits(matching: text)
|
||
if !hits.isEmpty {
|
||
Log.write("[dictionary] 命中:\(hits.joined(separator: ", "))")
|
||
NotificationCenter.default.post(name: .openLessDictionaryChanged, object: nil)
|
||
}
|
||
// 润色没真跑时,历史里的 mode 应反映「实际只是 raw」,避免误导。
|
||
let savedMode: PolishMode
|
||
switch polishOutcome {
|
||
case .ok: savedMode = mode
|
||
case .skippedNoCredentials, .failed: savedMode = .raw
|
||
}
|
||
switch result {
|
||
case .inserted:
|
||
capsule.update(state: capsuleStateForInsert(polishOutcome))
|
||
Log.write("[insert] OK (polish=\(polishOutcome.logTag))")
|
||
saveSession(
|
||
raw: raw,
|
||
final: text,
|
||
mode: savedMode,
|
||
app: frontApp,
|
||
status: .inserted,
|
||
errorCode: nil,
|
||
durationMs: durationMs,
|
||
dictionaryEntryCount: dictionaryEntryCount
|
||
)
|
||
case .copiedFallback(let reason):
|
||
capsule.update(state: capsuleStateForCopy(polishOutcome))
|
||
Log.write("[insert] fallback: \(reason) (polish=\(polishOutcome.logTag))")
|
||
saveSession(
|
||
raw: raw,
|
||
final: text,
|
||
mode: savedMode,
|
||
app: frontApp,
|
||
status: .copiedFallback,
|
||
errorCode: reason,
|
||
durationMs: durationMs,
|
||
dictionaryEntryCount: dictionaryEntryCount
|
||
)
|
||
}
|
||
// 处理结果留在屏幕上 2.5 秒:
|
||
// - 让用户有时间看到"已复制 ⌘V"提示并手动粘贴
|
||
// - "已插入"也保留较长时间,避免视觉一闪而过
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { [weak self] in
|
||
self?.capsule.update(state: .hidden)
|
||
}
|
||
}
|
||
|
||
private func capsuleStateForInsert(_ outcome: PolishOutcome) -> CapsuleState {
|
||
switch outcome {
|
||
case .ok: return .inserted
|
||
case .skippedNoCredentials: return .warning("已插入原文 · 未润色")
|
||
case .failed: return .warning("润色失败 · 已用原文")
|
||
}
|
||
}
|
||
|
||
private func capsuleStateForCopy(_ outcome: PolishOutcome) -> CapsuleState {
|
||
switch outcome {
|
||
case .ok: return .copied
|
||
case .skippedNoCredentials: return .warning("已复制原文 · 未润色 ⌘V")
|
||
case .failed: return .warning("润色失败 · 已复制 ⌘V")
|
||
}
|
||
}
|
||
|
||
private func runMockPipeline() async {
|
||
let mockText = "(演示)请到设置 → 凭据填入火山引擎 ASR + Ark API Key 后才能真实识别"
|
||
Log.write("[mock] \(mockText)")
|
||
try? await Task.sleep(nanoseconds: 800_000_000)
|
||
capsule.update(state: .copied)
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(mockText, forType: .string)
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in
|
||
self?.capsule.update(state: .hidden)
|
||
}
|
||
}
|
||
|
||
private func failSession(reason: String) async {
|
||
recorder.stop()
|
||
audioConsumer?.clear()
|
||
audioConsumer = nil
|
||
asr?.cancel()
|
||
asr = nil
|
||
sessionPhase = .idle
|
||
capsule.update(state: .error(reason))
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.6) { [weak self] in
|
||
self?.capsule.update(state: .hidden)
|
||
}
|
||
}
|
||
|
||
private func saveSession(
|
||
raw: String,
|
||
final: String,
|
||
mode: PolishMode,
|
||
app: NSRunningApplication?,
|
||
status: InsertStatus,
|
||
errorCode: String?,
|
||
durationMs: Int?,
|
||
dictionaryEntryCount: Int
|
||
) {
|
||
let session = DictationSession(
|
||
rawTranscript: raw,
|
||
finalText: final,
|
||
mode: mode,
|
||
appBundleId: app?.bundleIdentifier,
|
||
appName: app?.localizedName,
|
||
insertStatus: status,
|
||
errorCode: errorCode,
|
||
durationMs: durationMs,
|
||
dictionaryEntryCount: dictionaryEntryCount
|
||
)
|
||
history.save(session)
|
||
NotificationCenter.default.post(name: .openLessHistoryChanged, object: nil)
|
||
}
|
||
|
||
// MARK: - 凭据读取(全部走内存快照,不打 Keychain)
|
||
|
||
private func loadVolcengineCredentials() -> VolcengineCredentials? {
|
||
let app = credentials.volcengineAppKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
let access = credentials.volcengineAccessKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||
guard !app.isEmpty, !access.isEmpty else { return nil }
|
||
let resource = credentials.volcengineResourceId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
let resourceID = (resource?.isEmpty == false) ? resource! : VolcengineCredentials.defaultResourceId
|
||
return VolcengineCredentials(
|
||
appID: app,
|
||
accessToken: access,
|
||
resourceID: resourceID
|
||
)
|
||
}
|
||
|
||
private func loadArkCredentials() -> ArkCredentials? {
|
||
guard let key = credentials.arkApiKey, !key.isEmpty else { return nil }
|
||
let model = credentials.arkModelId ?? ArkCredentials.defaultModelId
|
||
let endpointStr = credentials.arkEndpoint ?? ArkCredentials.defaultEndpoint.absoluteString
|
||
let endpoint = URL(string: endpointStr) ?? ArkCredentials.defaultEndpoint
|
||
return ArkCredentials(apiKey: key, modelId: model, endpoint: endpoint)
|
||
}
|
||
}
|