feat(macos): local ASR model manager, menu-bar polish, and release 0.5.2

Adds a bundled local ASR model catalog for the macOS app with one-click
Sherpa Qwen3 / SenseVoice downloads (pause/resume, inline actions) and a
shared model storage directory used by MLX Qwen3. Fixes the light-mode
sidebar material and makes the menu-bar icon follow the system appearance
with a refreshed status mark. Renames the built product to OSGKeyboard.app.

Bumps version to 0.5.2 (build 19).
This commit is contained in:
Rocky
2026-07-09 08:55:37 +08:00
parent c2f07bd8d2
commit 200265fbd6
50 changed files with 4666 additions and 266 deletions
@@ -141,6 +141,7 @@ final class FlowSessionBridgeTests: XCTestCase {
func testDarwinNotificationPostsWithoutCrashing() {
FlowSessionDarwin.postSessionChanged()
FlowSessionDarwin.postCommandChanged()
FlowSessionDarwin.postHostReadyChanged()
}
@@ -183,4 +184,112 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertFalse(defaults.bool(forKey: FlowSessionKeys.flowHostReady))
XCTAssertFalse(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testFlowCommandRoundTrip() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
let command = FlowCommand(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 42,
action: .startRecording,
localeId: "zh-Hans",
createdAt: 123
)
FlowSessionBridge.writeCommand(command, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestCommand(defaults: defaults), command)
}
func testFlowResultRoundTripPreservesUtteranceIdentity() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
let result = FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 43,
status: .final,
text: "hello",
warning: "raw fallback",
createdAt: 124
)
FlowSessionBridge.writeResult(result, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestResult(defaults: defaults), result)
FlowSessionBridge.clearResult(defaults: defaults)
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
}
func testFlowAckRoundTrip() {
let defaults = makeDefaults()
let ack = FlowAck(
sessionId: UUID(),
utteranceId: UUID(),
commandSeq: 44,
consumedAt: 125
)
FlowSessionBridge.writeAck(ack, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.latestAck(defaults: defaults), ack)
}
func testReadySnapshotDrivesHostReady() {
let defaults = makeDefaults()
let sessionId = UUID()
let now = Date().timeIntervalSince1970
FlowSessionBridge.markSessionActive(duration: 60, sessionId: sessionId, defaults: defaults)
let snapshot = FlowReadySnapshot(
sessionId: sessionId,
ready: true,
reason: .ready,
heartbeatAt: now,
readyAt: now,
audioProofAt: now,
engineMode: "local",
localeId: "zh-Hans",
sessionExpiresAt: now + 60
)
FlowSessionBridge.writeReadySnapshot(snapshot, defaults: defaults)
XCTAssertEqual(FlowSessionBridge.readySnapshot(defaults: defaults), snapshot)
XCTAssertTrue(FlowSessionBridge.isHostReady(defaults: defaults))
}
func testClearFlowStateRemovesProtocolPayloads() {
let defaults = makeDefaults()
let sessionId = UUID()
let utteranceId = UUID()
FlowSessionBridge.writeCommand(
FlowCommand(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 1,
action: .startRecording,
localeId: "en-US"
),
defaults: defaults
)
FlowSessionBridge.writeResult(
FlowResult(
sessionId: sessionId,
utteranceId: utteranceId,
commandSeq: 1,
status: .partial,
text: "hello"
),
defaults: defaults
)
FlowSessionBridge.clearFlowState(defaults: defaults)
XCTAssertNil(FlowSessionBridge.latestCommand(defaults: defaults))
XCTAssertNil(FlowSessionBridge.latestResult(defaults: defaults))
XCTAssertNil(FlowSessionBridge.readySnapshot(defaults: defaults))
}
}
@@ -0,0 +1,124 @@
// LocalASRBiasAdapterTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class LocalASRBiasAdapterTests: XCTestCase {
private func makeFixtureLexicon() throws -> BuiltinLexiconIndex {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("osg-phrases-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let url = dir.appendingPathComponent("phrases.tsv")
let tsv = """
word\tpinyin\tsource\tweight
SwiftUI\tswift ui\tcomputer_terms\t5
Kubernetes\tku bo ne si\tcomputer_terms\t5
一致性\tyi zhi xing\tcomputer_terms\t5
"""
try tsv.write(to: url, atomically: true, encoding: .utf8)
addTeardownBlock {
try? FileManager.default.removeItem(at: dir)
}
return BuiltinLexiconIndex(fixtureURL: url)
}
func testAdaptBuildsPromptBiasForQwen3MLX() throws {
let lexicon = try makeFixtureLexicon()
var dict = PersonalDictionary.empty
_ = dict.upsertManual(term: "Cursor")
dict.updateAliases(for: dict.entries[0].id, aliases: ["cursor"])
let payload = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: dict,
locale: Locale(identifier: "zh-CN"),
capabilities: .qwen3MLX
),
lexicon: lexicon
)
XCTAssertNotNil(payload.promptBias)
XCTAssertTrue(payload.promptBias?.contains("Cursor") == true)
XCTAssertTrue(payload.promptBias?.contains("SwiftUI") == true)
XCTAssertEqual(payload.diagnostics.userTermCount, 2) // OSGKeyboard system + Cursor
XCTAssertGreaterThan(payload.diagnostics.builtinTermCount, 0)
}
func testAdaptProducesPolishFragmentWithoutUserDuplicates() throws {
let lexicon = try makeFixtureLexicon()
var dict = PersonalDictionary.empty
_ = dict.upsertManual(term: "SwiftUI")
let payload = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: dict,
locale: Locale(identifier: "zh-CN"),
capabilities: .qwen3MLX
),
lexicon: lexicon
)
XCTAssertFalse(payload.polishFragment.contains("SwiftUI"))
XCTAssertTrue(payload.polishFragment.contains("Kubernetes"))
}
func testCorrectionPairsFromAliases() {
var dict = PersonalDictionary.empty
_ = dict.upsertManual(term: "Kubernetes")
dict.updateAliases(for: dict.entries[0].id, aliases: ["k8s"])
let payload = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: dict,
locale: Locale(identifier: "zh-CN"),
capabilities: .qwen3MLX
),
lexicon: BuiltinLexiconIndex.shared
)
XCTAssertEqual(payload.correctionPairs.count, 1)
XCTAssertEqual(payload.correctionPairs[0].alias, "k8s")
XCTAssertEqual(payload.correctionPairs[0].term, "Kubernetes")
}
func testTranscriptCorrectorReplacesASCIIAlias() {
let pairs = [LocalASRCorrectionPair(alias: "k8s", term: "Kubernetes")]
let result = LocalASRTranscriptCorrector.apply(
"部署 k8s 集群",
pairs: pairs
)
XCTAssertEqual(result, "部署 Kubernetes 集群")
}
func testTranscriptCorrectorSkipsPartialASCIIMatch() {
let pairs = [LocalASRCorrectionPair(alias: "k8s", term: "Kubernetes")]
let result = LocalASRTranscriptCorrector.apply(
"xk8s集群",
pairs: pairs
)
XCTAssertEqual(result, "xk8s集群")
}
func testBuiltinLexiconParsesTSV() {
let terms = BuiltinLexiconIndex.parseTSV(
"word\tpinyin\tsource\tweight\nFoo\tfoo\tcomputer_terms\t5\n"
)
XCTAssertEqual(terms.count, 1)
XCTAssertEqual(terms[0].word, "Foo")
XCTAssertEqual(terms[0].weight, 5)
}
func testPolishingServiceMergesDictionarySupplement() {
let dict = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Cursor", category: .productName, source: .manual),
])
let merged = PolishingService.mergedDictionaryBlock(
dictionary: dict,
supplement: "内置技术词汇参考:SwiftUI"
)
XCTAssertTrue(merged.contains("Cursor"))
XCTAssertTrue(merged.contains("SwiftUI"))
}
}
@@ -0,0 +1,88 @@
// LocalASRModelCatalogTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class LocalASRModelCatalogTests: XCTestCase {
func testBundledCatalogLoads() throws {
let catalog = try LocalASRModelCatalog.loadBundled()
XCTAssertEqual(catalog.schemaVersion, 1)
XCTAssertFalse(catalog.models.isEmpty)
XCTAssertTrue(catalog.models.contains { $0.id == "qwen3-mlx-1.7b" })
XCTAssertTrue(catalog.models.contains { $0.id == "sherpa-qwen3-0.6b-int8" })
}
func testCapabilitiesForSherpaQwen3() throws {
let catalog = try LocalASRModelCatalog.loadBundled()
let model = try XCTUnwrap(LocalASRModelCatalog.model("sherpa-qwen3-0.6b-int8", in: catalog))
let caps = LocalASRModelCatalog.capabilities(for: model)
XCTAssertEqual(caps.hotwordMode, .recognizerScoped)
XCTAssertTrue(model.supportsHotwords)
}
func testManifestRoundTrip() throws {
let manifest = LocalASRInstalledManifest(
selectedModelId: "sherpa-qwen3-0.6b-int8",
installedModelIDs: ["sherpa-qwen3-0.6b-int8"]
)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("manifest-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: url) }
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try encoder.encode(manifest).write(to: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let loaded = try decoder.decode(LocalASRInstalledManifest.self, from: Data(contentsOf: url))
XCTAssertEqual(loaded.selectedModelId, manifest.selectedModelId)
XCTAssertEqual(loaded.installedModelIDs, manifest.installedModelIDs)
}
func testBiasDiagnosticsStoreRoundTrip() {
LocalASRBiasDiagnosticsStore.clear()
let payload = LocalASRBiasPayload(
hardHotwords: ["Cursor"],
promptBias: "test",
corpusContext: nil,
polishFragment: "fragment",
correctionPairs: [],
diagnostics: LocalASRBiasDiagnostics(userTermCount: 2, builtinTermCount: 3)
)
LocalASRBiasDiagnosticsStore.save(
payload: payload,
modelId: "qwen3-mlx-1.7b",
backendLabel: "MLX"
)
let snapshot = LocalASRBiasDiagnosticsStore.load()
XCTAssertEqual(snapshot?.modelId, "qwen3-mlx-1.7b")
XCTAssertEqual(snapshot?.diagnostics.userTermCount, 2)
XCTAssertEqual(snapshot?.hotwordCount, 1)
LocalASRBiasDiagnosticsStore.clear()
}
func testSherpaAdapterProducesHardHotwords() throws {
let fixtureURL = FileManager.default.temporaryDirectory
.appendingPathComponent("phrases-\(UUID().uuidString).tsv")
try "word\tpinyin\tsource\tweight\nSwiftUI\tswift ui\tcomputer_terms\t5\n"
.write(to: fixtureURL, atomically: true, encoding: .utf8)
defer { try? FileManager.default.removeItem(at: fixtureURL) }
var dict = PersonalDictionary.empty
_ = dict.upsertManual(term: "Kubernetes")
let payload = LocalASRBiasAdapter.adapt(
LocalASRBiasRequest(
dictionary: dict,
locale: Locale(identifier: "zh-CN"),
capabilities: .sherpaQwen3
),
lexicon: BuiltinLexiconIndex(fixtureURL: fixtureURL)
)
XCTAssertFalse(payload.hardHotwords.isEmpty)
XCTAssertTrue(payload.hardHotwords.contains("Kubernetes"))
}
}