feat(translation): add post-ASR translation mode for cloud engine
Adds an opt-in translation pipeline that reuses the existing PolishingService + LLMClient + AppGroupStore chain. Translation is implemented as a new PolishMode (.translate(targetLocaleId:)); all existing call sites are unchanged. Settings: - New TranslationPickerRow in the language tab (Toggle + 10-locale picker: en/zh-Hans/zh-Hant/ja/ko/fr/de/es/ru/pt), persisted to the App Group so the keyboard extension can read it during live dictation. - 5 new strings per language (en + zh-Hans). Keyboard: - New TranslationChip on the top bar to the right of LocaleChip; same Menu pattern, lets users toggle or quickly switch target language without leaving the keyboard. - PolishingService dispatches .translate with a parameterised prompt (en/zh variants selected by provider id); PolishingService.error gains a translationNotAvailable case so local-engine users get a clear inline warning when the toggle is on but cloud is off. - 6 new strings per language (en + zh-Hans) for the chip + banner. Local engine policy: - Translation is cloud-only by design (local engine stays ASR-only to honour the no-roundtrip promise). Chip shows a 'cloud required' state and raw transcript still inserts on failure — no data loss. Build: - OSGKeyboardShared adds TranslationLanguage enum (10 locales) and TranslationPrompt factory. - 4 new files, 9 modified. xcodebuild scheme=OSGKeyboard config=Debug destination=iPhone 17 Simulator: BUILD SUCCEEDED (0 warning, 0 error). Also pins DEVELOPMENT_TEAM in project.yml for TestFlight uploads (3 targets; Team X329MZU23S).
This commit is contained in:
@@ -138,6 +138,8 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||||
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
||||
state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) }
|
||||
state.setTranslationEnabled = { [weak self] enabled in self?.persistTranslationEnabled(enabled) }
|
||||
state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) }
|
||||
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
||||
@@ -528,8 +530,16 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
state.phase = .processing
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
// v0.2.1: pick the polish mode once at task start so a
|
||||
// mid-flight toggle flip doesn't change the request we
|
||||
// already sent. `isTranslationEffective` honours the cloud-
|
||||
// only constraint so we never accidentally translate on the
|
||||
// local engine.
|
||||
let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective
|
||||
? .translate(targetLocaleId: self.state.translationTargetLocaleId)
|
||||
: .polish
|
||||
do {
|
||||
let polished = try await self.polisher.polish(trimmed)
|
||||
let polished = try await self.polisher.polish(trimmed, mode: polishMode)
|
||||
self.textDocumentProxy.insertText(polished)
|
||||
self.state.lastTranscript = ""
|
||||
self.state.phase = .idle
|
||||
@@ -577,6 +587,19 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
message: ExtL10n.string("keyboard.error.llm.noApiKey")
|
||||
)
|
||||
self.scheduleAutoClearError()
|
||||
} catch let polishError as PolishingService.PolishError where polishError == .translationNotAvailable {
|
||||
// v0.2.1: user toggled translation on while the local
|
||||
// engine is active. Fall back to a plain polish — and if
|
||||
// we're on local-without-cloud-polish, fall all the way
|
||||
// back to raw ASR. The keyboard surfaces a short hint
|
||||
// telling them to switch to the cloud engine.
|
||||
self.textDocumentProxy.insertText(trimmed)
|
||||
self.state.lastTranscript = ""
|
||||
self.state.phase = .error(
|
||||
.unknown(ExtL10n.string("keyboard.error.translation.needsCloud")),
|
||||
message: ExtL10n.string("keyboard.error.translation.needsCloud")
|
||||
)
|
||||
self.scheduleAutoClearError()
|
||||
} catch {
|
||||
// Network / timeout / decoding — fall back to the raw
|
||||
// transcript so the user still gets their text, with a
|
||||
@@ -620,6 +643,28 @@ public final class KeyboardViewController: UIInputViewController {
|
||||
persistor.persist(localASRBackend: backend)
|
||||
}
|
||||
|
||||
// MARK: - Translation persistence
|
||||
|
||||
/// v0.2.1: persist translation toggle. When the user turns the
|
||||
/// feature on while the local engine is active we still write the
|
||||
/// value — `isTranslationEffective` will return `false` until they
|
||||
/// switch to cloud, but the chip on the keyboard reflects their
|
||||
/// intent immediately so they get feedback.
|
||||
private func persistTranslationEnabled(_ enabled: Bool) {
|
||||
state.translationEnabled = enabled
|
||||
persistor.persist(translationEnabled: enabled)
|
||||
}
|
||||
|
||||
/// v0.2.1: persist translation target locale id. Resolved via
|
||||
/// `TranslationLanguageCatalog.resolve` so a stale persisted value
|
||||
/// (e.g. a removed locale id from an older build) still finds the
|
||||
/// right entry instead of crashing the picker.
|
||||
private func persistTranslationTargetLocaleId(_ id: String) {
|
||||
let resolved = TranslationLanguageCatalog.resolve(id).id
|
||||
state.translationTargetLocaleId = resolved
|
||||
persistor.persist(translationTargetLocaleId: resolved)
|
||||
}
|
||||
|
||||
// MARK: - Open host app
|
||||
|
||||
private func openHostApp(path: String = "settings") {
|
||||
|
||||
Reference in New Issue
Block a user