Files
openless/Sources/OpenLessApp/DictationCoordinator.swift
T
baiqing 88a5a97812 feat: 1.0.03 — 侧边栏重构 + 词汇表 chip 布局 + 风格页 + 关掉自动学词
- 侧边栏 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
  → 解决"灰色边栏 + 黑色卡顿"
- 首页底部新增今日概览 + 风格预览
2026-04-28 11:16:40 +08:00

567 lines
20 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}