From deddb49d56bb5da03890e409e14c1af745fa4a4e Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 12:45:44 +0800 Subject: [PATCH] feat(translation): add post-ASR translation mode for cloud engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- OSGKeyboard/Views/SettingsView.swift | 7 + OSGKeyboard/Views/TranslationPickerRow.swift | 115 +++++++++++++++ OSGKeyboard/en.lproj/Localizable.strings | 5 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 5 + OSGKeyboardExt/KeyboardViewController.swift | 47 ++++++- .../Services/AppGroupPersistor.swift | 24 ++++ OSGKeyboardExt/Views/KeyboardRootView.swift | 5 + OSGKeyboardExt/Views/TranslationChip.swift | 132 ++++++++++++++++++ OSGKeyboardExt/en.lproj/Keyboard.strings | 9 ++ OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 9 ++ OSGKeyboardShared/Models/ProviderConfig.swift | 38 +++++ .../Models/TranslationLanguage.swift | 59 ++++++++ .../Services/AppGroupStore.swift | 32 +++++ .../Services/KeyboardState.swift | 20 +++ .../Services/PolishingService.swift | 48 ++++++- .../Services/TranslationPrompt.swift | 61 ++++++++ project.yml | 10 ++ 17 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 OSGKeyboard/Views/TranslationPickerRow.swift create mode 100644 OSGKeyboardExt/Views/TranslationChip.swift create mode 100644 OSGKeyboardShared/Models/TranslationLanguage.swift create mode 100644 OSGKeyboardShared/Services/TranslationPrompt.swift diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index e28aa54..d208b93 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -139,6 +139,13 @@ struct SettingsView: View { set: { config.localeId = $0 } ) ) + // v0.2.1: translation toggle + target-language picker. + // Sits at the bottom of the language section so the user + // finds it next to the ASR locale it complements. Reuses + // `config.translationEnabled` / `config.translationTargetLocaleId` + // bindings — no new state, no new persistence path. + Divider().background(palette.divider) + TranslationPickerRow(config: config) } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift new file mode 100644 index 0000000..d503d57 --- /dev/null +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -0,0 +1,115 @@ +// TranslationPickerRow.swift +// OSGKeyboard · Main App +// +// List row that hosts the translation toggle + target-language picker. +// Lives at the bottom of the language tab in Settings (see +// `SettingsView.languageAndModelsSection`) so it sits right next to +// the existing ASR locale picker — same picker family, same row +// metrics. +// +// Layout: +// • First row → switch (label on the left, switch on the right) +// • When on → a second row with a Menu picker for the target +// language. Disabled when the local engine is active so the user +// immediately sees why the picker is greyed out (instead of picking +// a target that the pipeline silently ignores). +// +// Reuses the host app's `ProviderConfig` `translationEnabled` / +// `translationTargetLocaleId` bindings — no new state, no new +// persistence path. + +import SwiftUI +import OSGKeyboardShared + +struct TranslationPickerRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + @ObservedObject var config: ProviderConfig + + var body: some View { + VStack(spacing: 0) { + toggleRow + if config.translationEnabled { + Divider().background(palette.divider) + targetRow + } + } + } + + private var toggleRow: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("settings.translation.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Text(subtitleKey) + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + } + Spacer() + Toggle( + "", + isOn: Binding( + get: { config.translationEnabled }, + set: { config.translationEnabled = $0 } + ) + ) + .labelsHidden() + .tint(palette.accent) + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + } + + /// Subtitle explains why the toggle is functionally inert when the + /// local engine is on. We still let the user flip the toggle in + /// that case so their preference is saved — the moment they switch + /// back to cloud, translation just works. + private var subtitleKey: LocalizedStringKey { + config.isLocalEngine + ? "settings.translation.subtitle.needsCloud" + : "settings.translation.subtitle" + } + + private var targetRow: some View { + HStack { + Text("settings.translation.target") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Menu { + ForEach(TranslationLanguageCatalog.all) { language in + Button { + config.translationTargetLocaleId = language.id + } label: { + if language.id == config.translationTargetLocaleId { + Label(language.nativeName, systemImage: "checkmark") + } else { + Text(language.nativeName) + } + } + } + } label: { + HStack(spacing: 6) { + Text(currentTargetName) + .font(TypeStyle.body) + .foregroundStyle(pickerForeground) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(palette.textTertiary) + } + } + .disabled(config.isLocalEngine) + .opacity(config.isLocalEngine ? 0.5 : 1.0) + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + } + + private var currentTargetName: String { + TranslationLanguageCatalog.resolve(config.translationTargetLocaleId).nativeName + } + + private var pickerForeground: Color { + config.isLocalEngine ? palette.textTertiary : palette.textSecondary + } +} \ No newline at end of file diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 2a8bd74..8834ae7 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -108,6 +108,11 @@ "provider.custom" = "Custom"; "settings.api.title" = "API"; "settings.language.title" = "Language"; +// v0.2.1: translation feature +"settings.translation.title" = "Translate after dictation"; +"settings.translation.subtitle" = "Send the transcript to your LLM with a translate-and-polish prompt before inserting."; +"settings.translation.subtitle.needsCloud" = "Requires the cloud engine — currently disabled."; +"settings.translation.target" = "Target language"; "settings.languageModels.title" = "Language & models"; "settings.localModels.title" = "On-device models"; "settings.localModels.speechRole" = "Speech"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index e1accb3..ef69f14 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -108,6 +108,11 @@ "provider.custom" = "自定义"; "settings.api.title" = "接口"; "settings.language.title" = "语言"; +// v0.2.1: 翻译功能 +"settings.translation.title" = "语音转文字后翻译"; +"settings.translation.subtitle" = "将转写文本发送给已配置的 LLM,使用翻译+润色 prompt 处理后插入。"; +"settings.translation.subtitle.needsCloud" = "需开启云端引擎,当前为本地模式暂不生效。"; +"settings.translation.target" = "目标语言"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 099a623..9ef159b 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -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") { diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index fb99354..8eadc83 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -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) + } } \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 83858f5..230a1e7 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -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) { diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift new file mode 100644 index 0000000..4cf08c7 --- /dev/null +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -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 + } +} \ No newline at end of file diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 1f34ba2..40fe44d 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -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"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index c0cd6e9..4dfa80f 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -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" = "转写"; diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index d2f9cdb..f85eb88 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -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" { diff --git a/OSGKeyboardShared/Models/TranslationLanguage.swift b/OSGKeyboardShared/Models/TranslationLanguage.swift new file mode 100644 index 0000000..1380c21 --- /dev/null +++ b/OSGKeyboardShared/Models/TranslationLanguage.swift @@ -0,0 +1,59 @@ +// TranslationLanguage.swift +// OSGKeyboard · Shared +// +// Catalog of target languages the translation feature can produce. +// +// Kept deliberately small (~10 entries) to match the kind of choices +// the user makes in the Settings picker / keyboard chip. We don't try +// to expose every BCP-47 locale — the prompt just needs a target +// language name, and a curated list reads better than a 100-row scroll. +// +// `id` is what gets persisted to the App Group. `promptLanguageName` +// is the human-readable target name injected into the prompt (e.g. +// the LLM sees "English", not "en"). `nativeName` is the endonym we +// show in the picker UI ("日本語" instead of "Japanese"). + +import Foundation + +public struct TranslationLanguage: Identifiable, Hashable, Sendable { + public let id: String + public let promptLanguageName: String + public let nativeName: String + + public init(id: String, promptLanguageName: String, nativeName: String) { + self.id = id + self.promptLanguageName = promptLanguageName + self.nativeName = nativeName + } +} + +public enum TranslationLanguageCatalog { + /// Default target language id used on fresh installs. + public static let defaultLocaleId = "en" + + /// Curated set. Order matters — the picker / chip render top-to- + /// bottom, and `defaultLocaleId` is the default selection. + public static let all: [TranslationLanguage] = [ + TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"), + TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"), + TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"), + TranslationLanguage(id: "ja", promptLanguageName: "Japanese", nativeName: "日本語"), + TranslationLanguage(id: "ko", promptLanguageName: "Korean", nativeName: "한국어"), + TranslationLanguage(id: "fr", promptLanguageName: "French", nativeName: "Français"), + TranslationLanguage(id: "de", promptLanguageName: "German", nativeName: "Deutsch"), + TranslationLanguage(id: "es", promptLanguageName: "Spanish", nativeName: "Español"), + TranslationLanguage(id: "ru", promptLanguageName: "Russian", nativeName: "Русский"), + TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"), + ] + + /// Resolve a stored locale id to its catalog entry. Falls back to + /// `defaultLocaleId` when the id is missing or unknown — matches the + /// pattern used elsewhere (e.g. `ASRLocaleLabels`) so the keyboard + /// never crashes on a stale persisted value. + public static func resolve(_ id: String) -> TranslationLanguage { + if let match = all.first(where: { $0.id == id }) { + return match + } + return all.first { $0.id == defaultLocaleId } ?? all[0] + } +} \ No newline at end of file diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index d2cc261..0f5918e 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -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 { diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index e0a23b4..a49d897 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -87,6 +87,20 @@ public final class KeyboardState: ObservableObject { /// CoreML local engine. Always `false` now — there are no weights /// for the host app to preload. @Published public var localModelsLoaded: Bool = false + /// v0.2.1: translation toggle mirrored from `ProviderConfig`. The + /// pipeline asks `isTranslationEffective` before honouring it — + /// the local engine ignores translation regardless of this flag. + @Published public var translationEnabled: Bool = false + /// v0.2.1: target locale id the translate-and-polish prompt should + /// produce (e.g. `"en"`, `"ja"`). Mirrored from `ProviderConfig`. + @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.defaultLocaleId + /// v0.2.1: effective predicate — translation is honoured only on + /// the cloud engine. The keyboard's chip / picker read this so the + /// UI can show a "需要云端" hint when the toggle is on while the + /// local engine is active. + public var isTranslationEffective: Bool { + translationEnabled && engineMode == "cloud" + } /// Convenience shorthand used by the pipeline and views. public var isLocalEngine: Bool { engineMode == "local" } @@ -101,6 +115,12 @@ public final class KeyboardState: ObservableObject { public var setLocale: (String) -> Void = { _ in } public var setEngineMode: (String) -> Void = { _ in } public var setLocalASRBackend: (LocalASRBackend) -> Void = { _ in } + /// v0.2.1: persist translation toggle. Wired in + /// `KeyboardViewController.installStateActions`. + public var setTranslationEnabled: (Bool) -> Void = { _ in } + /// v0.2.1: persist translation target locale id. Same wiring as + /// `setTranslationEnabled`. + public var setTranslationTargetLocaleId: (String) -> Void = { _ in } public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {} public var deleteBackward: () -> Void = {} diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index c607b73..720a57a 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -29,6 +29,19 @@ public actor PolishingService { /// telling them to fill it in; we deliver the raw transcript /// so no data is lost. case missingAPIKey + /// v0.2.1: the user requested translation but the active engine + /// can't honour it (e.g. `engineMode == "local"`). The keyboard + /// surfaces a short hint and falls back to the plain polish path. + case translationNotAvailable + } + + /// v0.2.1: what the LLM should do with the raw transcript. The + /// polish path stays the default so every existing call site keeps + /// its current behaviour — translation is opt-in via the `translate` + /// case and gets a target-locale parameter baked into the prompt. + public enum PolishMode: Equatable, Sendable { + case polish + case translate(targetLocaleId: String) } private let store: AppGroupStore @@ -52,10 +65,20 @@ public actor PolishingService { self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1) } - public func polish(_ raw: String) async throws -> String { + public func polish(_ raw: String, mode: PolishMode = .polish) async throws -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw PolishError.noTranscript } + // Translation requires the cloud engine (and therefore an API + // key + base URL). When the user toggles translation on while + // the local engine is active we refuse the mode so the keyboard + // can fall back to a plain polish (or raw ASR) and surface a + // short hint. This keeps the local engine's "ASR only" promise + // intact. + if case .translate = mode, store.engineMode != "cloud" { + throw PolishError.translationNotAvailable + } + // Local engine: ASR-only unless the user opted into cloud // polish via `localModeCloudPolishEnabled`. The cloud polish // path still requires an API key; if the Keychain is empty we @@ -66,15 +89,15 @@ public actor PolishingService { guard !store.apiKey.isEmpty else { throw PolishError.missingAPIKey } - return try await polishRemote(trimmed) + return try await polishRemote(trimmed, mode: mode) } - return try await polishRemote(trimmed) + return try await polishRemote(trimmed, mode: mode) } - private func polishRemote(_ trimmed: String) async throws -> String { + private func polishRemote(_ trimmed: String, mode: PolishMode) async throws -> String { let client = injectedClient ?? store.makeClient() - let prompt = store.systemPrompt + let prompt = resolvedSystemPrompt(for: mode) let budget = effectiveTimeout(for: trimmed) return try await withThrowingTaskGroup(of: String.self) { group in @@ -91,6 +114,21 @@ public actor PolishingService { } } + /// v0.2.1: pick the right system prompt for the requested mode. + /// Translation mode swaps in the parameterized translate-and-polish + /// prompt (see `TranslationPrompt.make`); polish mode keeps the + /// existing `store.systemPrompt` behaviour so every other call site + /// is byte-identical to before. + private func resolvedSystemPrompt(for mode: PolishMode) -> String { + switch mode { + case .polish: + return store.systemPrompt + case .translate(let targetLocaleId): + let target = TranslationLanguageCatalog.resolve(targetLocaleId) + return TranslationPrompt.make(target: target, providerId: store.providerId) + } + } + /// Scale polish budget with transcript length (3-minute Flow utterances). private func effectiveTimeout(for text: String) -> TimeInterval { let scaled = timeout + (Double(text.count) / 200.0) * 2.0 diff --git a/OSGKeyboardShared/Services/TranslationPrompt.swift b/OSGKeyboardShared/Services/TranslationPrompt.swift new file mode 100644 index 0000000..d43aa41 --- /dev/null +++ b/OSGKeyboardShared/Services/TranslationPrompt.swift @@ -0,0 +1,61 @@ +// TranslationPrompt.swift +// OSGKeyboard · Shared +// +// Builds the system prompt the LLM sees when the user has the +// translation toggle on. Re-uses the same per-provider "primary +// language" split the polish prompt uses (`AppGroupStore.defaultSystemPrompt`) +// so Chinese-native LLMs (DeepSeek, Qwen, GLM, Moonshot) get a Chinese +// prompt and English-native LLMs (OpenAI) get an English one — the LLM +// is most reliable when the instructions are written in its strongest +// language. +// +// The "translate AND polish" blend is intentional: ASR transcripts are +// noisy (homophone errors, broken segmentation, dropped particles), so +// the prompt asks the model to clean the noise while translating. +// Keeping those two concerns in one prompt matches how our existing +// polish prompt already mixes "preserve meaning" with "fix punctuation / +// drop filler". + +import Foundation + +public enum TranslationPrompt { + + /// Build the translate-and-polish system prompt. + /// + /// - Parameters: + /// - target: target language entry resolved via `TranslationLanguageCatalog`. + /// - providerId: provider preset id (e.g. `"deepseek"`, `"openai"`); + /// drives the language the prompt is written in. + public static func make(target: TranslationLanguage, providerId: String) -> String { + let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) + return isChineseNative ? chinesePrompt(target: target) : englishPrompt(target: target) + } + + // MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot) + + private static func chinesePrompt(target: TranslationLanguage) -> String { + """ + 你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述: + 1) 先识别原话的主要语言(若不确定则按用户给定的方向处理); + 2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测; + 3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然; + 4) 保留枚举结构(第一…第二…),使用「\(target.promptLanguageName)」的列表惯例; + 5) 简洁,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。 + """ + } + + // MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese) + + private static func englishPrompt(target: TranslationLanguage) -> String { + """ + You are a voice-input translation and polishing assistant. The user has spoken informally and the transcript may contain ASR noise: + 1) Identify the input language; if unclear, assume the user wants translation INTO \(target.promptLanguageName); + 2) Translate the content INTO \(target.promptLanguageName), preserving meaning; do not invent facts or omit content; + 3) Fix ASR noise (homophone errors, missing characters, broken segmentation) so the translation reads naturally; + 4) Preserve enumeration ("first ... second ...") using \(target.promptLanguageName) list conventions; + 5) Keep it concise — no longer than 1.5x the spoken length; drop filler words (um, uh, like); + 6) Output ONLY the translation. No quotes, no preamble, no explanation. + """ + } +} \ No newline at end of file diff --git a/project.yml b/project.yml index 3c36ea9..f556914 100644 --- a/project.yml +++ b/project.yml @@ -128,6 +128,9 @@ targets: SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + # TestFlight upload: Automatic signing picks the right App Store profile. + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S dependencies: - target: OSGKeyboardShared embed: true @@ -192,6 +195,9 @@ targets: SUPPORTS_MACCATALYST: NO SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO + # TestFlight upload: Automatic signing picks the right App Store profile. + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S dependencies: - target: OSGKeyboardShared embed: false @@ -223,6 +229,10 @@ targets: DYLIB_INSTALL_NAME_BASE: "@rpath" APPLICATION_EXTENSION_API_ONLY: YES ENABLE_MODULE_VERIFIER: YES + # Frameworks inherit the host app's signing identity; no profile + # is required, but pinning the team keeps the build reproducible. + CODE_SIGN_STYLE: Automatic + DEVELOPMENT_TEAM: X329MZU23S dependencies: - sdk: Speech.framework - sdk: AVFoundation.framework