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
@@ -35,6 +35,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// selection even though it never instantiates the backend itself.
static let localASRBackend = "config.localASRBackend"
static let uiLanguage = "config.uiLanguage"
// v0.2.0: optional cloud polish step after on-device ASR finishes
// in the local engine. Default `false` keeps the local engine
// truly local unless the user explicitly opts in.
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
}
@Published public var providerId: String {
@@ -96,6 +100,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
@Published public var localASRBackend: LocalASRBackend {
didSet { defaults.set(localASRBackend.rawValue, forKey: Key.localASRBackend) }
}
/// When `engineMode == "local"`, optionally route the ASR transcript
/// through the user's configured LLM (DeepSeek by default) before
/// inserting at the cursor. The polish step runs through the same
/// `LLMClient` + `PolishingService` stack the cloud engine uses.
///
/// Defaults to `false` the local engine is ASR-only out of the
/// box. Users opt in from Settings when the iOS ASR output isn't
/// strong enough (noisy far-field audio, dialectal Chinese, etc.).
@Published public var localModeCloudPolishEnabled: Bool {
didSet { defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled) }
}
/// Host-app UI language. Also mirrored to the App Group for the keyboard extension.
@Published public var uiLanguage: AppUILanguage {
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
@@ -114,6 +129,21 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// On-device ASR only; no cloud API required.
public var isLocalEngine: Bool { engineMode == "local" }
/// Whether a transcript produced by the local engine should be
/// sent through the cloud LLM polish step before insertion.
///
/// v0.2.0: the local engine defaults to ASR-only. When the user
/// enables "Cloud polish after ASR" (`localModeCloudPolishEnabled`)
/// we route the transcript through the configured LLM (DeepSeek by
/// default in local mode) same `PolishingService` code path the
/// cloud engine uses.
///
/// If the user hasn't entered an API key we can't run the polish
/// step; callers should check `Keychain.apiKey()` before invoking.
public var shouldPolishLocalTranscript: Bool {
isLocalEngine && localModeCloudPolishEnabled
}
/// The system prompt the user *sees* in the editor fall back to the
/// provider-aware default from `AppGroupStore` when nothing is set.
public var defaultSystemPrompt: String {
@@ -151,6 +181,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// rather than crashing inside `RawRepresentable.init`.
let rawBackend = resolvedDefaults.string(forKey: Key.localASRBackend) ?? LocalASRBackend.speechAnalyzer.rawValue
self.localASRBackend = LocalASRBackend(rawValue: rawBackend) ?? .speechAnalyzer
// v0.2.0: local-mode cloud polish toggle. Defaults off; users
// opt in from Settings when iOS ASR is too lossy for their
// environment. `object(forKey:) == nil` covers fresh installs
// and upgrades from builds that never wrote the key.
if resolvedDefaults.object(forKey: Key.localModeCloudPolishEnabled) == nil {
self.localModeCloudPolishEnabled = false
} else {
self.localModeCloudPolishEnabled = resolvedDefaults.bool(forKey: Key.localModeCloudPolishEnabled)
}
self.uiLanguage = AppUILanguage.fromStored(
resolvedDefaults.string(forKey: Key.uiLanguage)
)