refactor: drop Qwen3 CoreML ASR, add local-engine cloud polish toggle

Rolls back the v0.2.0 Qwen3 CoreML on-device ASR stack and replaces the
'local engine' UX with iOS 26 SpeechAnalyzer + DictationTranscriber only.

The 'Cloud polish after ASR' toggle (ProviderConfig.localModeCloudPolishEnabled)
lets users opt into a post-ASR DeepSeek round-trip from the local engine.
Defaults to off so the local engine stays genuinely local. New PolishError.missingAPIError
surfaces an inline 'fill in your key' warning when the toggle is on but the
Keychain is empty. DeepSeek preset default model bumped to deepseek-v4-flash.

Deleted:
  - OSGKeyboard/ThirdParty/Qwen3Speech/ (74 files, ~16k LoC)
  - OSGKeyboard/Services/ModelManager.swift (492)
  - OSGKeyboard/Services/OnDeviceModelWarmup.swift (197)
  - OSGKeyboard/Services/Qwen3ASRService.swift (257)
  - OSGKeyboard/Services/ModelDownloadSourcePicker.swift (126)
  - OSGKeyboard/Views/OnDeviceModelsView.swift (184)
  - OSGKeyboard/Views/DownloadConfirmSheet.swift (96)
  - OSGKeyboardShared/Models/OnDeviceModel.swift (140)
  - OSGKeyboardShared/Services/OnDeviceModelStatus.swift (104)
  - Qwen3ASRServiceProvider registration in OSGKeyboardApp
  - Qwen3Speech package declaration in project.yml
  - 5 .qwen3ASR enum / branch reference sites in HomeView, OnboardingView,
    LocalEngineSettingsRows, FlowSessionManager, ASRService, EngineServiceLabel
  - Two pre-existing Swift 6 strict-concurrency errors in
    LiveDictationController + FlowSessionManager (the weak [weak self] in
    detached-task MainActor.run blocks) that were blocking clean builds

Added:
  - LocalModelsGroup: 'Built-in iOS SpeechAnalyzer' badge + 'Cloud polish
    after ASR' Switch toggle
  - PolishingService: honour localModeCloudPolishEnabled; new .missingAPIKey
    error case with localised warning
  - AppGroupStore.localModeCloudPolishEnabled (mirrored into App Group
    so the keyboard extension honours the toggle during live dictation)
  - SettingsView: show provider/api sections when local-mode cloud polish
    is on so the user can paste a DeepSeek key
  - FlowSessionManager: route through PolishingService for local + polish-on
    flow; translate missingAPIKey into a polished warning
  - KeyboardViewController: handle PolishingService.PolishError.missingAPIKey
    in the keyboard-side live polish path
  - CHANGELOG v0.2.1: documents the rollback + new toggle
  - README.md / README.zh.md: engine matrix section, data flow note

Verified: xcodebuild -scheme OSGKeyboard -destination 'generic/platform=iOS Simulator'
build succeeds under SWIFT_STRICT_CONCURRENCY=complete.
This commit is contained in:
2026-06-24 01:51:34 +08:00
parent 39690c0a93
commit c07cf4db9f
119 changed files with 401 additions and 18858 deletions
+50 -17
View File
@@ -22,15 +22,20 @@ final class FlowSessionManager: ObservableObject {
private let capture = FlowContinuousCapture()
private let store = AppGroupStore()
/// Cloud-engine polish only; local engine delivers raw ASR text.
/// Cloud-engine polish; local engine now ALSO runs through the
/// polisher when `localModeCloudPolishEnabled` is on the same
/// `PolishingService` short-circuits to raw when the toggle is off.
private var polisher: PolishingService {
PolishingService()
}
/// Cached ASR instance shared with `OnDeviceModelWarmup`.
/// Cached ASR instance. v0.2.0: the only on-device backend is iOS
/// `SpeechAnalyzer`, which has no warm-up step we can hand the
/// factory-built service straight back without going through the
/// old `OnDeviceModelWarmup` registry.
private var sessionASR: ASRService?
private var asr: ASRService {
if let sessionASR { return sessionASR }
let service = OnDeviceModelWarmup.shared.asrService(
let service = ASRServiceFactory.make(
engineMode: store.engineMode,
localBackend: store.localASRBackend
)
@@ -144,7 +149,9 @@ final class FlowSessionManager: ObservableObject {
startLevelPublishing()
scheduleExpiry(after: remaining)
OnDeviceModelWarmup.shared.warmUpIfNeeded()
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up. We still
// re-bind the cached `sessionASR` so a config flip mid-session
// (e.g. switching from cloud to local) is honoured.
bindSessionASR()
debug("Flow session restored (\(Int(remaining))s remaining)")
@@ -256,7 +263,8 @@ final class FlowSessionManager: ObservableObject {
Task { @MainActor [weak self] in
await self?.reactivateCaptureIfNeeded()
OnDeviceModelWarmup.shared.ensureReadyAfterBackground()
// v0.2.0: iOS `SpeechAnalyzer` is bundled with the OS; no
// on-device weights to reload after a background trip.
self?.bindSessionASR()
}
}
@@ -320,14 +328,16 @@ final class FlowSessionManager: ObservableObject {
startLevelPublishing()
scheduleExpiry(after: duration)
OnDeviceModelWarmup.shared.warmUpIfNeeded()
// v0.2.0: iOS `SpeechAnalyzer` needs no warm-up; just refresh
// the cached ASR service in case the user flipped engines
// while the session was idle.
bindSessionASR()
debug("Flow session started (\(Int(duration))s), continuous capture running")
}
private func bindSessionASR() {
sessionASR = OnDeviceModelWarmup.shared.asrService(
sessionASR = ASRServiceFactory.make(
engineMode: store.engineMode,
localBackend: store.localASRBackend
)
@@ -396,7 +406,6 @@ final class FlowSessionManager: ObservableObject {
isUtteranceRecording = true
FlowDiagnostics.log(
"beginUtterance engine=\(store.engineMode) asr=\(store.localASRBackend.rawValue) " +
"modelsInMemory=\(OnDeviceModelStatus.modelsLoadedInMemory()) " +
"asrType=\(type(of: asr)) pipelined=true max=\(Int(FlowSessionKeys.maxUtteranceDuration))s"
)
@@ -406,7 +415,11 @@ final class FlowSessionManager: ObservableObject {
manager?.currentPartial = partial
}
}
await MainActor.run {
// Re-bind `manager` inside the `@MainActor` block so the
// weak reference is captured under the right isolation. Swift
// 6 strict concurrency otherwise complains about a
// task-isolated reference escaping into a main-actor closure.
await MainActor.run { [weak manager] in
guard let manager else { return }
FlowDiagnostics.log(
"chunkedASR finished partialLen=\(manager.currentPartial.count) " +
@@ -546,10 +559,13 @@ final class FlowSessionManager: ObservableObject {
}
let engineMode = store.engineMode
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
let shouldPolish = (engineMode == "cloud")
|| (engineMode == "local" && store.localModeCloudPolishEnabled)
if engineMode == "local" {
let warning = Self.chunkWarningMessage(chunkWarnings)
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
if !shouldPolish {
// Local engine, cloud-polish toggle off pure ASR.
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote)
FlowDiagnostics.log(
"finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " +
"len=\(text.count)"
@@ -563,7 +579,6 @@ final class FlowSessionManager: ObservableObject {
}
var delivered = text
let chunkNote = Self.chunkWarningMessage(chunkWarnings)
let polishStarted = Date()
do {
let polished = try await polisher.polish(text)
@@ -574,11 +589,17 @@ final class FlowSessionManager: ObservableObject {
"total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s"
)
} catch {
// v0.2.0: local + cloud-polish-on + no API key surfaces
// `.missingAPIKey`. We translate it into a polishWarning
// so the keyboard can show the "fill in your key" hint
// inline rather than a generic failure message. The raw
// transcript is still delivered no data loss.
let warning = Self.warningFromPolishError(error) ?? chunkNote
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
)
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote)
FlowSessionBridge.storeTranscriptionResult(text, polishWarning: warning)
}
SpeechHistoryStore.shared.append(text: delivered, engineMode: engineMode)
@@ -595,11 +616,23 @@ final class FlowSessionManager: ObservableObject {
return warnings.joined(separator: "\n")
}
/// v0.2.0: surface the local-mode cloud-polish error path with a
/// localised hint ("please fill in your DeepSeek key in Settings")
/// rather than letting the keyboard show a generic network error.
private static func warningFromPolishError(_ error: Error) -> String? {
guard let polishError = error as? PolishingService.PolishError,
polishError == .missingAPIKey else {
return nil
}
return AppL10n.string("flow.warning.cloudPolishMissingKey")
}
private func asrWaitTimeout() -> TimeInterval {
// v0.2.0: local engine is iOS `SpeechAnalyzer` only, so the
// previous Qwen3-specific timeout collapses into the shared
// local path.
if store.engineMode == "local" {
return store.localASRBackend == .qwen3ASR
? FlowSessionKeys.localQwen3ASRWaitTimeout
: FlowSessionKeys.localASRWaitTimeout
return FlowSessionKeys.localASRWaitTimeout
}
return FlowSessionKeys.cloudASRWaitTimeout
}