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") {
|
||||
|
||||
@@ -35,6 +35,12 @@ public struct AppGroupPersistor {
|
||||
state.mode = .polish
|
||||
state.engineMode = store.engineMode
|
||||
state.localASRBackend = store.localASRBackend
|
||||
// v0.2.1: translation toggle + target locale. Read once at
|
||||
// hydration; `refreshRuntimeFlags` keeps them in sync while the
|
||||
// keyboard stays open so a Settings change shows up without a
|
||||
// re-present cycle.
|
||||
state.translationEnabled = store.translationEnabled
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
// 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".
|
||||
@@ -75,6 +81,11 @@ public struct AppGroupPersistor {
|
||||
let store = AppGroupStore()
|
||||
state.engineMode = store.engineMode
|
||||
state.localASRBackend = store.localASRBackend
|
||||
// v0.2.1: keep translation state in sync with the host app so the
|
||||
// chip on the keyboard reflects the latest value without a re-
|
||||
// present cycle.
|
||||
state.translationEnabled = store.translationEnabled
|
||||
state.translationTargetLocaleId = store.translationTargetLocaleId
|
||||
// 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.
|
||||
@@ -105,4 +116,17 @@ public struct AppGroupPersistor {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setLocalASRBackend(localASRBackend)
|
||||
}
|
||||
|
||||
/// v0.2.1: persist translation toggle. Wired through the
|
||||
/// `KeyboardViewController.setTranslation` action hook.
|
||||
public func persist(translationEnabled: Bool) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setTranslationEnabled(translationEnabled)
|
||||
}
|
||||
|
||||
/// v0.2.1: persist translation target locale id (e.g. `"en"`).
|
||||
public func persist(translationTargetLocaleId: String) {
|
||||
guard AppGroup.isAvailable else { return }
|
||||
AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,11 @@ public struct KeyboardRootView: View {
|
||||
LocaleChip(localeId: state.localeId) { newId in
|
||||
state.setLocale(newId)
|
||||
}
|
||||
// v0.2.1: translation chip — sits next to the locale picker
|
||||
// and doubles as both the on/off switch and the target-
|
||||
// language picker (Menu pattern matches LocaleChip so the
|
||||
// top bar stays visually consistent).
|
||||
TranslationChip(state: state)
|
||||
Spacer(minLength: 0)
|
||||
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
||||
Button(action: state.openSettings) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// TranslationChip.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Compact chip rendered to the right of `LocaleChip` on the keyboard
|
||||
// top bar. Doubles as both the on/off switch and the target-language
|
||||
// picker — same Menu pattern as `LocaleChip` so muscle memory transfers.
|
||||
//
|
||||
// Visual states:
|
||||
// • disabled → dim outline, "翻译" label
|
||||
// • enabled + cloud → accent fill, "→ EN" / "→ 日本語" style label
|
||||
// • enabled + local engine→ warning fill + "翻译需云端" hint (effectively
|
||||
// inert; the pipeline rejects the mode and the controller surfaces
|
||||
// the error toast)
|
||||
//
|
||||
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
|
||||
// (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar
|
||||
// doesn't grow when translation is enabled.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct TranslationChip: View {
|
||||
@Environment(\.themePalette) private var palette: ThemePalette
|
||||
|
||||
@ObservedObject var state: KeyboardViewController.State
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
// Toggle entry sits at the top so the user can flip the feature
|
||||
// without picking a language first.
|
||||
Button {
|
||||
state.setTranslationEnabled(!state.translationEnabled)
|
||||
} label: {
|
||||
if state.translationEnabled {
|
||||
Label(ExtL10n.string("keyboard.translation.disable"), systemImage: "checkmark")
|
||||
} else {
|
||||
Text(ExtL10n.string("keyboard.translation.enable"))
|
||||
}
|
||||
}
|
||||
if state.translationEnabled {
|
||||
Divider()
|
||||
ForEach(TranslationLanguageCatalog.all) { language in
|
||||
Button {
|
||||
state.setTranslationTargetLocaleId(language.id)
|
||||
} label: {
|
||||
if language.id == state.translationTargetLocaleId {
|
||||
Label(language.nativeName, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(language.nativeName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
label
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.accessibilityLabel(ExtL10n.text("keyboard.translation.a11y"))
|
||||
.accessibilityHint(ExtL10n.text("keyboard.translation.a11yHint"))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var label: some View {
|
||||
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
|
||||
let isLocal = state.isLocalEngine
|
||||
let enabled = state.translationEnabled
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
|
||||
Text(chipLabel(target: target, enabled: enabled, isLocal: isLocal))
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
}
|
||||
.font(TypeStyle.caption2)
|
||||
.foregroundStyle(foreground(enabled: enabled, isLocal: isLocal))
|
||||
.padding(.horizontal, Spacing.xs + 2)
|
||||
.padding(.vertical, 5)
|
||||
.frame(minHeight: 26)
|
||||
.background(background(enabled: enabled, isLocal: isLocal), in: Capsule())
|
||||
.overlay(Capsule().stroke(stroke(enabled: enabled, isLocal: isLocal), lineWidth: 0.5))
|
||||
}
|
||||
|
||||
private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String {
|
||||
// Local-engine + on shows the constraint hint instead of the
|
||||
// target label so the user knows why nothing's happening.
|
||||
if enabled, isLocal {
|
||||
return ExtL10n.string("keyboard.translation.needsCloudShort")
|
||||
}
|
||||
if !enabled {
|
||||
return ExtL10n.string("keyboard.translation.off")
|
||||
}
|
||||
// Short form: "→EN" / "→日" style. Falls back to the prompt
|
||||
// language name for languages without a chip-style abbreviation
|
||||
// (e.g. French → "FR" via the 2-letter prefix).
|
||||
let short = shortLabel(for: target)
|
||||
return "→\(short)"
|
||||
}
|
||||
|
||||
private func shortLabel(for target: TranslationLanguage) -> String {
|
||||
switch target.id {
|
||||
case "en": return "EN"
|
||||
case "zh-Hans": return "中"
|
||||
case "zh-Hant": return "繁"
|
||||
case "ja": return "日"
|
||||
case "ko": return "韩"
|
||||
case "fr": return "FR"
|
||||
case "de": return "DE"
|
||||
case "es": return "ES"
|
||||
case "ru": return "RU"
|
||||
case "pt": return "PT"
|
||||
default: return target.promptLanguageName
|
||||
}
|
||||
}
|
||||
|
||||
private func foreground(enabled: Bool, isLocal: Bool) -> Color {
|
||||
if enabled, isLocal { return palette.warning }
|
||||
if enabled { return palette.accent }
|
||||
return palette.textPrimary
|
||||
}
|
||||
|
||||
private func background(enabled: Bool, isLocal: Bool) -> Color {
|
||||
if enabled, isLocal { return palette.warning.opacity(0.15) }
|
||||
if enabled { return palette.accent.opacity(0.15) }
|
||||
return palette.surfaceElevated
|
||||
}
|
||||
|
||||
private func stroke(enabled: Bool, isLocal: Bool) -> Color {
|
||||
if enabled, isLocal { return palette.warning.opacity(0.35) }
|
||||
if enabled { return palette.accent.opacity(0.35) }
|
||||
return palette.divider
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,15 @@
|
||||
"locale.chip.ja-JP" = "日";
|
||||
"locale.chip.ko-KR" = "韩";
|
||||
|
||||
/* Translation chip (v0.2.1) */
|
||||
"keyboard.translation.off" = "Translate";
|
||||
"keyboard.translation.enable" = "Enable translation";
|
||||
"keyboard.translation.disable" = "Disable translation";
|
||||
"keyboard.translation.needsCloudShort" = "Need cloud";
|
||||
"keyboard.translation.a11y" = "Translation";
|
||||
"keyboard.translation.a11yHint" = "Toggle translation or change the target language.";
|
||||
"keyboard.error.translation.needsCloud" = "Translation needs the cloud engine — switch to cloud in Settings.";
|
||||
|
||||
/* Mode chip labels (used in both ext + preview stub) */
|
||||
"mode.off" = "Off";
|
||||
"mode.transcribe" = "Transcribe";
|
||||
|
||||
@@ -177,6 +177,15 @@
|
||||
"locale.chip.ja-JP" = "日";
|
||||
"locale.chip.ko-KR" = "韩";
|
||||
|
||||
/* 翻译 chip (v0.2.1) */
|
||||
"keyboard.translation.off" = "翻译";
|
||||
"keyboard.translation.enable" = "开启翻译";
|
||||
"keyboard.translation.disable" = "关闭翻译";
|
||||
"keyboard.translation.needsCloudShort" = "需云端";
|
||||
"keyboard.translation.a11y" = "翻译";
|
||||
"keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。";
|
||||
"keyboard.error.translation.needsCloud" = "翻译需要云端引擎,请到设置切换为云端模式。";
|
||||
|
||||
/* Mode chip labels */
|
||||
"mode.off" = "关闭";
|
||||
"mode.transcribe" = "转写";
|
||||
|
||||
Reference in New Issue
Block a user