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:
2026-06-25 12:45:44 +08:00
parent dc9697bf3d
commit deddb49d56
17 changed files with 620 additions and 6 deletions
@@ -39,6 +39,9 @@ public struct AppGroupStore: @unchecked Sendable {
static let uiLanguage = "config.uiLanguage"
// v0.2.0: opt-in cloud polish step after local-mode ASR.
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
// v0.2.1: translation toggle + target locale id (e.g. "en").
static let translationEnabled = "config.translationEnabled"
static let translationTargetLocaleId = "config.translationTargetLocaleId"
}
// MARK: - Reads
@@ -104,6 +107,22 @@ public struct AppGroupStore: @unchecked Sendable {
AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage))
}
/// v0.2.1: whether the keyboard should translate the post-ASR transcript
/// before inserting it. Honored only when `engineMode == "cloud"` see
/// `ProviderConfig.isTranslationEffective` for the effective predicate.
public var translationEnabled: Bool {
guard defaults.object(forKey: Key.translationEnabled) != nil else {
return false
}
return defaults.bool(forKey: Key.translationEnabled)
}
/// v0.2.1: target locale id the translate-and-polish prompt should
/// produce (e.g. `"en"`). Defaults to `"en"` when nothing is stored.
public var translationTargetLocaleId: String {
defaults.string(forKey: Key.translationTargetLocaleId) ?? "en"
}
// MARK: - Writes
public func setModeId(_ id: String) {
@@ -126,6 +145,19 @@ public struct AppGroupStore: @unchecked Sendable {
defaults.set(language.rawValue, forKey: Key.uiLanguage)
}
/// v0.2.1: persist translation toggle. The keyboard extension reads
/// this on every `load()` and `refreshRuntimeFlags()` so the chip
/// reflects the latest value without a host-app round-trip.
public func setTranslationEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.translationEnabled)
}
/// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`). Same
/// read cadence as `setTranslationEnabled`.
public func setTranslationTargetLocaleId(_ id: String) {
defaults.set(id, forKey: Key.translationTargetLocaleId)
}
// MARK: - Client
public func makeClient() -> LLMClient {