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
+7
View File
@@ -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(
@@ -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
}
}
+5
View File
@@ -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";
@@ -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" = "语音识别";