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,12 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// in the local engine. Default `false` keeps the local engine
// truly local unless the user explicitly opts in.
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
// v0.2.1: optional translation step after ASR. When enabled, the
// post-ASR transcript is routed through the same LLM with a
// translate-and-polish prompt targeting `translationTargetLocaleId`.
// Mutually exclusive with the local-only promise see `TranslationPolicy`.
static let translationEnabled = "config.translationEnabled"
static let translationTargetLocaleId = "config.translationTargetLocaleId"
}
@Published public var providerId: String {
@@ -115,6 +121,29 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
@Published public var uiLanguage: AppUILanguage {
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
}
/// v0.2.1: whether to translate the transcript into
/// `translationTargetLocaleId` before insertion. Persisted in the App
/// Group so the keyboard extension can honour it (and so the chip on
/// the keyboard reflects the user's choice without a host-app round-
/// trip). Default `false` translation is opt-in.
@Published public var translationEnabled: Bool {
didSet { defaults.set(translationEnabled, forKey: Key.translationEnabled) }
}
/// v0.2.1: BCP-47-ish target language id (e.g. `en`, `ja`, `ko`) the
/// translate-and-polish prompt should produce. Default `"en"`.
/// Persisted in the App Group for the same reason as `translationEnabled`.
@Published public var translationTargetLocaleId: String {
didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) }
}
/// v0.2.1: hard gate that decides whether the translation feature can
/// actually run. The keyboard honors `translationEnabled` only when
/// `engineMode == "cloud"` the local engine is contractually ASR-
/// only, so translation is silently ignored (and the UI shows a hint)
/// even when the toggle is on.
public var isTranslationEffective: Bool {
translationEnabled && engineMode == "cloud"
}
public var isConfigured: Bool {
// Local engine (on-device ASR only) doesn't need an API key,
@@ -193,6 +222,15 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.uiLanguage = AppUILanguage.fromStored(
resolvedDefaults.string(forKey: Key.uiLanguage)
)
// v0.2.1: translation toggle + target locale. Both default in a
// backwards-compatible way so existing installs keep their old
// behaviour (`false`/English) without prompting.
if resolvedDefaults.object(forKey: Key.translationEnabled) == nil {
self.translationEnabled = false
} else {
self.translationEnabled = resolvedDefaults.bool(forKey: Key.translationEnabled)
}
self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) ?? "en"
// Cloud no longer exposes off/transcribe; migrate legacy values.
if self.engineMode == "cloud", self.modeId != "polish" {