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 }
|
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))
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
||||||
.overlay(
|
.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";
|
"provider.custom" = "Custom";
|
||||||
"settings.api.title" = "API";
|
"settings.api.title" = "API";
|
||||||
"settings.language.title" = "Language";
|
"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.languageModels.title" = "Language & models";
|
||||||
"settings.localModels.title" = "On-device models";
|
"settings.localModels.title" = "On-device models";
|
||||||
"settings.localModels.speechRole" = "Speech";
|
"settings.localModels.speechRole" = "Speech";
|
||||||
|
|||||||
@@ -108,6 +108,11 @@
|
|||||||
"provider.custom" = "自定义";
|
"provider.custom" = "自定义";
|
||||||
"settings.api.title" = "接口";
|
"settings.api.title" = "接口";
|
||||||
"settings.language.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.languageModels.title" = "语言与模型";
|
||||||
"settings.localModels.title" = "本地模型";
|
"settings.localModels.title" = "本地模型";
|
||||||
"settings.localModels.speechRole" = "语音识别";
|
"settings.localModels.speechRole" = "语音识别";
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
state.setLocale = { [weak self] l in self?.persistLocale(l) }
|
||||||
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
state.setEngineMode = { [weak self] m in self?.persistEngineMode(m) }
|
||||||
state.setLocalASRBackend = { [weak self] b in self?.persistLocalASRBackend(b) }
|
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.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
|
||||||
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
|
||||||
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
|
||||||
@@ -528,8 +530,16 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
state.phase = .processing
|
state.phase = .processing
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
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 {
|
do {
|
||||||
let polished = try await self.polisher.polish(trimmed)
|
let polished = try await self.polisher.polish(trimmed, mode: polishMode)
|
||||||
self.textDocumentProxy.insertText(polished)
|
self.textDocumentProxy.insertText(polished)
|
||||||
self.state.lastTranscript = ""
|
self.state.lastTranscript = ""
|
||||||
self.state.phase = .idle
|
self.state.phase = .idle
|
||||||
@@ -577,6 +587,19 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
message: ExtL10n.string("keyboard.error.llm.noApiKey")
|
message: ExtL10n.string("keyboard.error.llm.noApiKey")
|
||||||
)
|
)
|
||||||
self.scheduleAutoClearError()
|
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 {
|
} catch {
|
||||||
// Network / timeout / decoding — fall back to the raw
|
// Network / timeout / decoding — fall back to the raw
|
||||||
// transcript so the user still gets their text, with a
|
// transcript so the user still gets their text, with a
|
||||||
@@ -620,6 +643,28 @@ public final class KeyboardViewController: UIInputViewController {
|
|||||||
persistor.persist(localASRBackend: backend)
|
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
|
// MARK: - Open host app
|
||||||
|
|
||||||
private func openHostApp(path: String = "settings") {
|
private func openHostApp(path: String = "settings") {
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ public struct AppGroupPersistor {
|
|||||||
state.mode = .polish
|
state.mode = .polish
|
||||||
state.engineMode = store.engineMode
|
state.engineMode = store.engineMode
|
||||||
state.localASRBackend = store.localASRBackend
|
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
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
|
||||||
// into the State flags so downstream consumers see the same
|
// into the State flags so downstream consumers see the same
|
||||||
// shape they did when the previous Qwen3 stack reported "ready".
|
// shape they did when the previous Qwen3 stack reported "ready".
|
||||||
@@ -75,6 +81,11 @@ public struct AppGroupPersistor {
|
|||||||
let store = AppGroupStore()
|
let store = AppGroupStore()
|
||||||
state.engineMode = store.engineMode
|
state.engineMode = store.engineMode
|
||||||
state.localASRBackend = store.localASRBackend
|
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
|
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
|
||||||
// toggles here so the keyboard UI doesn't flicker if the host
|
// toggles here so the keyboard UI doesn't flicker if the host
|
||||||
// app briefly clears them while refactoring.
|
// app briefly clears them while refactoring.
|
||||||
@@ -105,4 +116,17 @@ public struct AppGroupPersistor {
|
|||||||
guard AppGroup.isAvailable else { return }
|
guard AppGroup.isAvailable else { return }
|
||||||
AppGroupStore().setLocalASRBackend(localASRBackend)
|
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
|
LocaleChip(localeId: state.localeId) { newId in
|
||||||
state.setLocale(newId)
|
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)
|
Spacer(minLength: 0)
|
||||||
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported)
|
||||||
Button(action: state.openSettings) {
|
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.ja-JP" = "日";
|
||||||
"locale.chip.ko-KR" = "韩";
|
"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 chip labels (used in both ext + preview stub) */
|
||||||
"mode.off" = "Off";
|
"mode.off" = "Off";
|
||||||
"mode.transcribe" = "Transcribe";
|
"mode.transcribe" = "Transcribe";
|
||||||
|
|||||||
@@ -177,6 +177,15 @@
|
|||||||
"locale.chip.ja-JP" = "日";
|
"locale.chip.ja-JP" = "日";
|
||||||
"locale.chip.ko-KR" = "韩";
|
"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 chip labels */
|
||||||
"mode.off" = "关闭";
|
"mode.off" = "关闭";
|
||||||
"mode.transcribe" = "转写";
|
"mode.transcribe" = "转写";
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
// in the local engine. Default `false` — keeps the local engine
|
// in the local engine. Default `false` — keeps the local engine
|
||||||
// truly local unless the user explicitly opts in.
|
// truly local unless the user explicitly opts in.
|
||||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
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 {
|
@Published public var providerId: String {
|
||||||
@@ -115,6 +121,29 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
|||||||
@Published public var uiLanguage: AppUILanguage {
|
@Published public var uiLanguage: AppUILanguage {
|
||||||
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
|
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 {
|
public var isConfigured: Bool {
|
||||||
// Local engine (on-device ASR only) doesn't need an API key,
|
// 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(
|
self.uiLanguage = AppUILanguage.fromStored(
|
||||||
resolvedDefaults.string(forKey: Key.uiLanguage)
|
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.
|
// Cloud no longer exposes off/transcribe; migrate legacy values.
|
||||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,9 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
static let uiLanguage = "config.uiLanguage"
|
static let uiLanguage = "config.uiLanguage"
|
||||||
// v0.2.0: opt-in cloud polish step after local-mode ASR.
|
// v0.2.0: opt-in cloud polish step after local-mode ASR.
|
||||||
static let localModeCloudPolishEnabled = "config.localModeCloudPolishEnabled"
|
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
|
// MARK: - Reads
|
||||||
@@ -104,6 +107,22 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage))
|
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
|
// MARK: - Writes
|
||||||
|
|
||||||
public func setModeId(_ id: String) {
|
public func setModeId(_ id: String) {
|
||||||
@@ -126,6 +145,19 @@ public struct AppGroupStore: @unchecked Sendable {
|
|||||||
defaults.set(language.rawValue, forKey: Key.uiLanguage)
|
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
|
// MARK: - Client
|
||||||
|
|
||||||
public func makeClient() -> LLMClient {
|
public func makeClient() -> LLMClient {
|
||||||
|
|||||||
@@ -87,6 +87,20 @@ public final class KeyboardState: ObservableObject {
|
|||||||
/// CoreML local engine. Always `false` now — there are no weights
|
/// CoreML local engine. Always `false` now — there are no weights
|
||||||
/// for the host app to preload.
|
/// for the host app to preload.
|
||||||
@Published public var localModelsLoaded: Bool = false
|
@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.
|
/// Convenience shorthand used by the pipeline and views.
|
||||||
public var isLocalEngine: Bool { engineMode == "local" }
|
public var isLocalEngine: Bool { engineMode == "local" }
|
||||||
@@ -101,6 +115,12 @@ public final class KeyboardState: ObservableObject {
|
|||||||
public var setLocale: (String) -> Void = { _ in }
|
public var setLocale: (String) -> Void = { _ in }
|
||||||
public var setEngineMode: (String) -> Void = { _ in }
|
public var setEngineMode: (String) -> Void = { _ in }
|
||||||
public var setLocalASRBackend: (LocalASRBackend) -> 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 insertNewline: () -> Void = {}
|
||||||
public var insertSpace: () -> Void = {}
|
public var insertSpace: () -> Void = {}
|
||||||
public var deleteBackward: () -> Void = {}
|
public var deleteBackward: () -> Void = {}
|
||||||
|
|||||||
@@ -29,6 +29,19 @@ public actor PolishingService {
|
|||||||
/// telling them to fill it in; we deliver the raw transcript
|
/// telling them to fill it in; we deliver the raw transcript
|
||||||
/// so no data is lost.
|
/// so no data is lost.
|
||||||
case missingAPIKey
|
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
|
private let store: AppGroupStore
|
||||||
@@ -52,10 +65,20 @@ public actor PolishingService {
|
|||||||
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
|
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)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
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
|
// Local engine: ASR-only unless the user opted into cloud
|
||||||
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
// polish via `localModeCloudPolishEnabled`. The cloud polish
|
||||||
// path still requires an API key; if the Keychain is empty we
|
// path still requires an API key; if the Keychain is empty we
|
||||||
@@ -66,15 +89,15 @@ public actor PolishingService {
|
|||||||
guard !store.apiKey.isEmpty else {
|
guard !store.apiKey.isEmpty else {
|
||||||
throw PolishError.missingAPIKey
|
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 client = injectedClient ?? store.makeClient()
|
||||||
let prompt = store.systemPrompt
|
let prompt = resolvedSystemPrompt(for: mode)
|
||||||
let budget = effectiveTimeout(for: trimmed)
|
let budget = effectiveTimeout(for: trimmed)
|
||||||
|
|
||||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
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).
|
/// Scale polish budget with transcript length (3-minute Flow utterances).
|
||||||
private func effectiveTimeout(for text: String) -> TimeInterval {
|
private func effectiveTimeout(for text: String) -> TimeInterval {
|
||||||
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
||||||
|
|||||||
@@ -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.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
@@ -128,6 +128,9 @@ targets:
|
|||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
|
SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
|
# TestFlight upload: Automatic signing picks the right App Store profile.
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
DEVELOPMENT_TEAM: X329MZU23S
|
||||||
dependencies:
|
dependencies:
|
||||||
- target: OSGKeyboardShared
|
- target: OSGKeyboardShared
|
||||||
embed: true
|
embed: true
|
||||||
@@ -192,6 +195,9 @@ targets:
|
|||||||
SUPPORTS_MACCATALYST: NO
|
SUPPORTS_MACCATALYST: NO
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||||
SUPPORTS_XR_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:
|
dependencies:
|
||||||
- target: OSGKeyboardShared
|
- target: OSGKeyboardShared
|
||||||
embed: false
|
embed: false
|
||||||
@@ -223,6 +229,10 @@ targets:
|
|||||||
DYLIB_INSTALL_NAME_BASE: "@rpath"
|
DYLIB_INSTALL_NAME_BASE: "@rpath"
|
||||||
APPLICATION_EXTENSION_API_ONLY: YES
|
APPLICATION_EXTENSION_API_ONLY: YES
|
||||||
ENABLE_MODULE_VERIFIER: 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:
|
dependencies:
|
||||||
- sdk: Speech.framework
|
- sdk: Speech.framework
|
||||||
- sdk: AVFoundation.framework
|
- sdk: AVFoundation.framework
|
||||||
|
|||||||
Reference in New Issue
Block a user