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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" = "语音识别";
|
||||
|
||||
Reference in New Issue
Block a user