perf(asr): speed up local Flow dictation and land CLM/keyboard refactor

Reduce perceived latency from key release to final text:
- Adaptive chunking: 2.5s first chunk + 5s follow-ups so short
  utterances start on-device recognition while still recording.
- Session-level ASR warmup and audio-format cache reuse to remove
  per-utterance cold-start of SpeechAnalyzer.
- Mirror live pipelined partials to the keyboard transcript line via
  a new flow.transcriptionPartial App Group key + Darwin ping.

Also commits the accumulated custom language model, Flow session,
keyboard extension restructure, and Xiaomi MiMo provider work in
progress on this branch.
This commit is contained in:
Rocky
2026-07-06 00:00:19 +08:00
parent cfbfb542cc
commit 537a68552a
76 changed files with 3456 additions and 121086 deletions
@@ -34,7 +34,6 @@ public struct AppGroupPersistor {
// Both engines always polish; ignore legacy off/transcribe modeId.
state.mode = .polish
state.engineMode = store.engineMode
state.localASRBackend = store.localASRBackend
// v0.2.1 follow-up: only the target locale is persisted
// `translationEnabled` is derived from it. Hydrate once at
// startup; `refreshRuntimeFlags` keeps the chip in sync while
@@ -42,16 +41,10 @@ public struct AppGroupPersistor {
state.translationTargetLocaleId = store.translationTargetLocaleId
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
: ""
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
// into the State flags so downstream consumers see the same
// shape they did when the previous Qwen3 stack reported "ready".
state.localModelsReady = true
state.localModelsLoaded = false
#if DEBUG
// Print a masked view of the live App Group config so we can see
@@ -74,7 +67,6 @@ public struct AppGroupPersistor {
model = \(store.model)
modeId = \(store.modeId)
localeId = \(store.localeId)
localASRBackend = \(store.localASRBackend.rawValue)
""")
#endif
return .loaded
@@ -93,8 +85,6 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
let store = AppGroupStore()
state.engineMode = store.engineMode
state.localASRBackend = store.localASRBackend
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false
if !shouldProtectTranslation {
state.translationTargetLocaleId = store.translationTargetLocaleId
@@ -105,11 +95,6 @@ public struct AppGroupPersistor {
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
: ""
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
// toggles here so the keyboard UI doesn't flicker if the host
// app briefly clears them while refactoring.
state.localModelsReady = true
state.localModelsLoaded = false
}
/// Persist `mode` to the App Group store.
@@ -130,12 +115,6 @@ public struct AppGroupPersistor {
AppGroupStore().setEngineMode(engineMode)
}
/// Persist `localASRBackend` to the App Group store.
public func persist(localASRBackend: LocalASRBackend) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setLocalASRBackend(localASRBackend)
}
/// v0.2.1: persist translation target locale id (e.g. `"en"`,
/// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The
/// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`.
@@ -149,4 +128,4 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
}
}
}
@@ -0,0 +1,127 @@
// CursorDragController.swift
// OSGKeyboard · Keyboard Extension
//
// Cursor-drag hint chrome and batched caret moves via textDocumentProxy.
import UIKit
import OSGKeyboardShared
@MainActor
final class CursorDragController {
private let state: KeyboardState
private let adjustTextPosition: (Int) -> Void
private weak var parentView: UIView?
private var cursorDragHintLabel: UILabel?
private var pendingHorizontalCursorSteps = 0
private var pendingVerticalCursorSteps = 0
private var cursorMoveFlushScheduled = false
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
private static let cursorVerticalChunkSize = 20
init(
state: KeyboardState,
adjustTextPosition: @escaping (Int) -> Void
) {
self.state = state
self.adjustTextPosition = adjustTextPosition
}
func install(on view: UIView) {
parentView = view
let hint = UILabel()
hint.text = ExtL10n.string("keyboard.cursorDrag.centerHint")
hint.font = .systemFont(ofSize: 22, weight: .medium)
hint.textColor = UIColor.label.withAlphaComponent(0.10)
hint.textAlignment = .center
hint.numberOfLines = 1
hint.adjustsFontSizeToFitWidth = true
hint.minimumScaleFactor = 0.7
hint.isUserInteractionEnabled = false
hint.isHidden = true
hint.alpha = 0
view.addSubview(hint)
cursorDragHintLabel = hint
layoutChrome()
}
func layoutChrome() {
guard let view = parentView else { return }
cursorDragHintLabel?.frame = view.bounds
}
func setCursorDragActive(_ active: Bool) {
state.cursorDragActive = active
updateCursorDragWash(active: active)
}
func moveCursorHorizontally(by steps: Int) {
guard steps != 0 else { return }
pendingHorizontalCursorSteps += steps
scheduleCursorMoveFlush()
}
func moveCursorVertically(by steps: Int) {
guard steps != 0 else { return }
pendingVerticalCursorSteps += steps
scheduleCursorMoveFlush()
}
private func updateCursorDragWash(active: Bool) {
if active {
cursorLineHaptic.prepare()
}
layoutChrome()
guard let hint = cursorDragHintLabel else { return }
if active {
hint.isHidden = false
UIView.animate(withDuration: 0.12) { hint.alpha = 1 }
} else {
UIView.animate(withDuration: 0.12, animations: { hint.alpha = 0 }) { [weak self] _ in
guard let self, !self.state.cursorDragActive else { return }
hint.isHidden = true
}
}
}
private func scheduleCursorMoveFlush() {
guard !cursorMoveFlushScheduled else { return }
cursorMoveFlushScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.012) { [weak self] in
guard let self else { return }
self.cursorMoveFlushScheduled = false
let horizontal = self.pendingHorizontalCursorSteps
let vertical = self.pendingVerticalCursorSteps
self.pendingHorizontalCursorSteps = 0
self.pendingVerticalCursorSteps = 0
if horizontal != 0 {
OSGLog.keyboardExt.info("adjustTextPosition h=\(horizontal)")
self.adjustTextPosition(horizontal)
}
if vertical != 0 {
self.applyVerticalCursorSteps(vertical)
}
if self.pendingHorizontalCursorSteps != 0 || self.pendingVerticalCursorSteps != 0 {
self.scheduleCursorMoveFlush()
}
}
}
private func applyVerticalCursorSteps(_ steps: Int) {
let direction = steps > 0 ? 1 : -1
var remaining = abs(steps)
let chunk = Self.cursorVerticalChunkSize
while remaining > 0 {
adjustTextPosition(direction * chunk)
cursorLineHaptic.impactOccurred()
cursorLineHaptic.prepare()
remaining -= 1
}
}
}
@@ -0,0 +1,124 @@
// KeyboardConfigSync.swift
// OSGKeyboard · Keyboard Extension
//
// App Group config hydration, Darwin observers, and onboarding mirroring.
import Foundation
import OSGKeyboardShared
@MainActor
final class KeyboardConfigSync {
private let state: KeyboardState
private let persistor: AppGroupPersistor
private let onFlowSessionChanged: () -> Void
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
var translationConfigProtectedUntil: Date?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var transcriptionDarwinObserver: FlowSessionDarwinObserver?
private var configDarwinObserver: FlowSessionDarwinObserver?
init(
state: KeyboardState,
persistor: AppGroupPersistor,
onFlowSessionChanged: @escaping () -> Void
) {
self.state = state
self.persistor = persistor
self.onFlowSessionChanged = onFlowSessionChanged
}
func installDarwinObservers() {
flowSessionDarwinObserver = FlowSessionDarwinObserver { [weak self] in
self?.onFlowSessionChanged()
}
transcriptionDarwinObserver = FlowSessionDarwinObserver(
notificationName: FlowSessionDarwin.transcriptionNotificationName
) { [weak self] in
self?.onFlowSessionChanged()
}
configDarwinObserver = FlowSessionDarwinObserver(
notificationName: AppGroupConfigDarwin.notificationName
) { [weak self] in
self?.refreshConfigFromAppGroup()
}
}
func loadPersistedConfig() -> AppGroupLoadResult {
switch persistor.load(into: state) {
case .loaded:
OSGLog.keyboardExt.info(
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
)
syncOnboardingStateFromAppGroup()
return .loaded
case .unavailable:
state.phase = .error(
.appGroupUnavailable,
message: ExtL10n.string("keyboard.error.appGroupUnavailable")
)
return .unavailable
}
}
func refreshConfigFromAppGroup() {
persistor.refreshRuntimeFlags(
into: state,
protectTranslationUntil: translationConfigProtectedUntil
)
}
func syncOnboardingStateFromAppGroup() {
let store = AppGroupStore()
state.hasCompletedOnboarding = store.hasCompletedOnboarding
state.onboardingPage = store.onboardingPage
}
func autoAdvancePastKeyboardSetupStepIfNeeded() {
guard !state.hasCompletedOnboarding else { return }
guard state.onboardingPage == 3 else { return }
guard KeyboardSetupBridge.isReadyForOnboardingSkip else { return }
let store = AppGroupStore()
store.setOnboardingPage(4)
state.onboardingPage = 4
}
func advanceOnboarding() {
let store = AppGroupStore()
let nextPage = min(4, store.onboardingPage + 1)
store.setOnboardingPage(nextPage)
state.onboardingPage = nextPage
}
func completeOnboarding() {
let store = AppGroupStore()
store.setHasCompletedOnboarding(true)
store.setOnboardingPage(4)
state.hasCompletedOnboarding = true
state.onboardingPage = 4
}
func persistLocale(_ id: String) {
state.localeId = id
persistor.persist(localeId: id)
}
func persistEngineMode(_ mode: String) {
state.engineMode = mode
persistor.persist(engineMode: mode)
}
func persistTranslationTargetLocaleId(_ id: String) {
let resolved = TranslationLanguageCatalog.resolve(id).id
state.translationTargetLocaleId = resolved
translationConfigProtectedUntil = Date().addingTimeInterval(2.5)
persistor.persist(translationTargetLocaleId: resolved)
}
func persistMode(_ mode: KeyboardState.InputMode) {
state.mode = mode
persistor.persist(mode: mode)
}
}
@@ -0,0 +1,412 @@
// KeyboardFlowCoordinator.swift
// OSGKeyboard · Keyboard Extension
//
// Flow session start, recording, watchdogs, and result delivery handling.
import UIKit
import OSGKeyboardShared
@MainActor
final class KeyboardFlowCoordinator {
private enum FlowWatchdog {
static let pollIntervalNs: UInt64 = 200_000_000
/// Give the user time to manually open the host app when auto-jump fails.
static let startTimeout: TimeInterval = 30
static func resultTimeout(engineMode: String) -> TimeInterval {
FlowSessionKeys.keyboardResultTimeout(engineMode: engineMode)
}
}
private let state: KeyboardState
private let textInserter: KeyboardTextInserter
private let hasFullAccess: () -> Bool
private let wakeLockView: () -> UIView?
private let openHostApp: (String) -> Void
private let detectAndStoreAppContext: () -> Void
private let scheduleAutoClearError: () -> Void
private let refreshConfigFromAppGroup: () -> Void
private var isPendingFlowStart = false
private var flowStartDeadline: TimeInterval = 0
private var isFlowRecording = false
private var flowWatchdogTask: Task<Void, Never>?
private var utteranceTimerTask: Task<Void, Never>?
private var utteranceStartedAt: TimeInterval = 0
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>?
private var isAwaitingFlowResult = false
private var lastFlowAutoStartAttempt: TimeInterval = 0
private static let flowAutoStartCooldown: TimeInterval = 20
init(
state: KeyboardState,
textInserter: KeyboardTextInserter,
hasFullAccess: @escaping () -> Bool,
wakeLockView: @escaping () -> UIView?,
openHostApp: @escaping (String) -> Void,
detectAndStoreAppContext: @escaping () -> Void,
scheduleAutoClearError: @escaping () -> Void,
refreshConfigFromAppGroup: @escaping () -> Void
) {
self.state = state
self.textInserter = textInserter
self.hasFullAccess = hasFullAccess
self.wakeLockView = wakeLockView
self.openHostApp = openHostApp
self.detectAndStoreAppContext = detectAndStoreAppContext
self.scheduleAutoClearError = scheduleAutoClearError
self.refreshConfigFromAppGroup = refreshConfigFromAppGroup
}
var preservesLifecycleOnDisappear: Bool {
isPendingFlowStart || isFlowRecording || isAwaitingFlowResult
}
func startSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.refreshSessionState()
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
func stopSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = nil
}
func refreshSessionState() {
refreshConfigFromAppGroup()
refreshFlowPartialIfNeeded()
consumePendingFlowDeliveryIfNeeded()
let active = FlowSessionBridge.isSessionActive()
state.flowSessionActive = active
if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart {
switch state.phase {
case .recording, .processing:
break
default:
showFlowSessionExpiredHint()
}
}
wasFlowSessionActive = active
if !active {
maybeAutoStartFlowSession()
}
}
func toggleRecording() {
switch state.phase {
case .recording:
pressEnded()
case .idle, .denied, .error:
pressBegan()
case .requestingPermissions, .processing:
break
}
}
func pressBegan() {
switch state.phase {
case .idle, .denied, .error:
break
default:
return
}
guard !state.micDisabled else { return }
guard hasFullAccess() else {
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.fullAccessRequired, message: msg)
scheduleAutoClearError()
return
}
guard AppGroup.isAvailable else {
let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError()
return
}
detectAndStoreAppContext()
if FlowSessionBridge.isSessionActive() {
startFlowRecording()
} else {
beginFlowStart()
}
}
func pressEnded() {
if isPendingFlowStart {
cancelPendingFlowStart()
return
}
guard isFlowRecording else { return }
isFlowRecording = false
stopUtteranceCountdown()
ExtensionScreenWakeLock.release()
FlowSessionBridge.setRecordingState(.stopped)
state.phase = .processing
state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
startFlowResultWatchdog()
}
func beginFlowStart() {
guard !isPendingFlowStart else { return }
isPendingFlowStart = true
isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
state.phase = .processing
openHostApp("startflow")
startFlowStartWatchdog()
debug("beginFlowStart")
}
func handleHostAppOpenResult(path: String, success: Bool) {
debug("openHostApp path=\(path) success=\(success)")
guard !success else { return }
if path == "startflow", isPendingFlowStart {
state.lastTranscript = ExtL10n.string("keyboard.flow.manualOpenHost")
return
}
showManualOpenHint(path: path)
}
func cancelPipelineUnlessAwaitingResult() {
guard !isAwaitingFlowResult else { return }
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted)
ExtensionScreenWakeLock.release()
}
isFlowRecording = false
isPendingFlowStart = false
stopUtteranceCountdown()
stopFlowWatchdog()
state.level = 0
}
}
// MARK: - Private
private func consumePendingFlowDeliveryIfNeeded() {
if isAwaitingFlowResult {
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
isAwaitingFlowResult = false
stopFlowWatchdog()
textInserter.handleFlowTranscript(delivery)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
isAwaitingFlowResult = false
stopFlowWatchdog()
state.phase = .error(
.fromFlowTranscription(error),
message: error.message
)
scheduleAutoClearError()
return
}
}
if isPendingFlowStart, FlowSessionBridge.isSessionActive() {
completeFlowStartHandoff()
}
}
private func maybeAutoStartFlowSession() {
guard !FlowSessionBridge.isSessionActive() else { return }
guard !isPendingFlowStart, !isFlowRecording, !isAwaitingFlowResult else { return }
guard hasFullAccess(), AppGroup.isAvailable else { return }
guard case .idle = state.phase else { return }
let now = Date().timeIntervalSince1970
guard now - lastFlowAutoStartAttempt >= Self.flowAutoStartCooldown else { return }
lastFlowAutoStartAttempt = now
beginFlowStart()
}
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.flowSessionExpired, message: message)
scheduleAutoClearError()
}
private func showManualOpenHint(path: String) {
let msg: String
if !hasFullAccess() {
msg = ExtL10n.string("keyboard.error.fullAccessForJump")
} else if path == "settings" {
msg = ExtL10n.string("keyboard.error.manualOpenSettings")
} else if path == "startflow" {
msg = ExtL10n.string("keyboard.error.manualOpenForFlow")
} else {
msg = ExtL10n.string("keyboard.error.manualOpenSettings")
}
state.phase = .error(.manualOpenRequired, message: msg)
scheduleAutoClearError()
}
private func startFlowRecording() {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
FlowSessionBridge.setTranscriptionLanguage(state.localeId)
FlowSessionBridge.setRecordingState(.recording)
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
if let view = wakeLockView() {
ExtensionScreenWakeLock.acquire(from: view)
}
startUtteranceCountdown()
startFlowLevelWatchdog()
debug("startFlowRecording")
}
private func startUtteranceCountdown() {
utteranceStartedAt = Date().timeIntervalSince1970
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
utteranceTimerTask?.cancel()
utteranceTimerTask = Task { @MainActor [weak self] in
while let self, self.isFlowRecording, !Task.isCancelled {
let elapsed = Date().timeIntervalSince1970 - self.utteranceStartedAt
let remaining = max(0, Int(ceil(FlowSessionKeys.maxUtteranceDuration - elapsed)))
self.state.utteranceRemainingSeconds = remaining
if remaining <= 0 {
self.pressEnded()
return
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
}
}
private func stopUtteranceCountdown() {
utteranceTimerTask?.cancel()
utteranceTimerTask = nil
state.utteranceRemainingSeconds = Int(FlowSessionKeys.maxUtteranceDuration)
}
private func cancelPendingFlowStart() {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
state.phase = .idle
state.lastTranscript = ""
}
private func startFlowStartWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isPendingFlowStart {
if FlowSessionBridge.isSessionActive() {
self.completeFlowStartHandoff()
return
}
let now = Date().timeIntervalSince1970
if self.flowStartDeadline > 0, now > self.flowStartDeadline {
self.isPendingFlowStart = false
self.flowStartDeadline = 0
self.showManualOpenHint(path: "startflow")
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func completeFlowStartHandoff() {
isPendingFlowStart = false
flowStartDeadline = 0
stopFlowWatchdog()
state.lastTranscript = ""
state.phase = .idle
refreshSessionState()
debug("completeFlowStartHandoff")
}
private func startFlowLevelWatchdog() {
stopFlowWatchdog()
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled, self.isFlowRecording {
let levels = FlowSessionBridge.audioLevels()
if let peak = levels.max(), peak > 0 {
self.state.level = Double(peak)
}
self.refreshFlowPartialIfNeeded()
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func refreshFlowPartialIfNeeded() {
guard isFlowRecording || isAwaitingFlowResult else { return }
switch state.phase {
case .recording, .processing:
if let partial = FlowSessionBridge.transcriptionPartial() {
state.lastTranscript = partial
}
default:
break
}
}
private func startFlowResultWatchdog() {
stopFlowWatchdog()
isAwaitingFlowResult = true
let startedAt = Date().timeIntervalSince1970
let resultTimeout = FlowWatchdog.resultTimeout(engineMode: state.engineMode)
flowWatchdogTask = Task { @MainActor [weak self] in
while let self, !Task.isCancelled {
if let delivery = FlowSessionBridge.consumeTranscriptionDelivery() {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.textInserter.handleFlowTranscript(delivery)
return
}
if let error = FlowSessionBridge.consumeTranscriptionError() {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
self.state.phase = .error(
.fromFlowTranscription(error),
message: error.message
)
self.scheduleAutoClearError()
return
}
self.refreshFlowPartialIfNeeded()
let now = Date().timeIntervalSince1970
if now - startedAt > resultTimeout {
self.isAwaitingFlowResult = false
self.stopFlowWatchdog()
let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.flowResultTimeout, message: msg)
self.scheduleAutoClearError()
return
}
try? await Task.sleep(nanoseconds: FlowWatchdog.pollIntervalNs)
}
}
}
private func stopFlowWatchdog() {
flowWatchdogTask?.cancel()
flowWatchdogTask = nil
}
private func debug(_ message: String) {
OSGLog.keyboardExt.info("\(message, privacy: .public)")
}
}
@@ -0,0 +1,44 @@
// KeyboardTextInserter.swift
// OSGKeyboard · Keyboard Extension
//
// Inserts Flow transcripts from the host app and surfaces polish warnings
// without re-running LLM polish in the extension.
import OSGKeyboardShared
@MainActor
final class KeyboardTextInserter {
private let state: KeyboardState
private let insertText: (String) -> Void
private let scheduleAutoClearError: () -> Void
init(
state: KeyboardState,
insertText: @escaping (String) -> Void,
scheduleAutoClearError: @escaping () -> Void
) {
self.state = state
self.insertText = insertText
self.scheduleAutoClearError = scheduleAutoClearError
}
func handleFlowTranscript(_ delivery: TranscriptionDelivery) {
let trimmed = delivery.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
state.phase = .idle
state.level = 0
return
}
// Host app already polished when configured; keyboard only inserts.
insertText(trimmed)
state.lastTranscript = ""
state.level = 0
if let warning = delivery.polishWarning {
state.phase = .error(.polishDegraded(warning), message: warning)
scheduleAutoClearError()
} else {
state.phase = .idle
}
OSGLog.keyboardExt.info("flow insert length=\(trimmed.count, privacy: .public)")
}
}