From deddb49d56bb5da03890e409e14c1af745fa4a4e Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 12:45:44 +0800 Subject: [PATCH 1/8] 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 From 4d92999347d35f47deae7823024a5fe176cacf9f Mon Sep 17 00:00:00 2001 From: zhongshu Date: Thu, 25 Jun 2026 13:23:54 +0800 Subject: [PATCH 2/8] feat(translation-ui): tighten settings/onboarding UX around the translation feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI refinements on top of the translation pipeline (feature/translation@HEAD): 1. Onboarding engine page now hosts a translation row. APISetupPage renders the same TranslationPickerRow used in the language tab, so first-time users can pick a target language before they ever see the keyboard. Same persisted bindings; same 'needs cloud' hint when the local engine is active. 2. Local engine hides the provider / API card unconditionally. Removed the 'local + cloud polish on → show API fields' branch from SettingsView. Provider/base URL/API key/model controls have no use in local mode (translation is cloud-only anyway), and exposing them invited users to fill in a DeepSeek key they can't use. 3. 'Cloud polish after ASR' toggle loses its long subtitle. The descriptive copy in LocalEngineSettingsRows.cloudPolishRow was a wall of text that explained things visible elsewhere in Settings. Title + switch is enough; the CloudPolishDisclosureBanner (rendered by EnginePickerSection when cloud is active) already covers the 'this sends text to your API' disclosure. 4. Translation row becomes a single dropdown with a 'Don't translate' default. TranslationPickerRow replaced with a one-row Menu picker: '不翻译 / English / 中文 (简体) / 中文 (繁體) / 日本語 / 한국어 / Français / Deutsch / Español / Русский / Português'. '不翻译' maps to translationEnabled=false; any locale maps to translationEnabled=true + translationTargetLocaleId=. TranslationLanguageCatalog gains an 'off' sentinel so the picker's single binding stays a plain String. 5. Language tab reorder. SettingsView.languageAndModelsSection: ASR locale ('识别语言') now sits above the local-models block; translation row sits at the bottom. The reading order follows the pipeline direction (input → post-processing → post-post-processing). Localization: - 'settings.translation.title' → '翻译' / 'Translation' - new 'settings.translation.off' / 'settings.translation.hint.needsCloud' - dropped unused subtitle / target-language keys xcodebuild scheme=OSGKeyboard config=Debug destination=iPhone 17 Simulator: BUILD SUCCEEDED (0 warning, 0 error). --- .../Views/LocalEngineSettingsRows.swift | 28 ++-- OSGKeyboard/Views/OnboardingView.swift | 28 ++++ OSGKeyboard/Views/SettingsView.swift | 34 ++--- OSGKeyboard/Views/TranslationPickerRow.swift | 138 +++++++++--------- OSGKeyboard/en.lproj/Localizable.strings | 7 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 7 +- .../Models/TranslationLanguage.swift | 31 +++- 7 files changed, 156 insertions(+), 117 deletions(-) diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index 6de8120..f5632e1 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -57,25 +57,21 @@ struct LocalModelsGroup: View { /// itself is always live (the user can flip it without having a /// key yet), but the polish call short-circuits with an Alert if /// the Keychain is empty when it fires. + /// + /// v0.2.1: dropped the long descriptive subtitle — it explained + /// things the user could read about elsewhere in Settings + /// (provider / API key section) and made the row visually heavy. + /// Title + switch is enough; details live in `CloudPolishDisclosureBanner`. private var cloudPolishRow: some View { - VStack(alignment: .leading, spacing: Spacing.xs) { - Toggle(isOn: $config.localModeCloudPolishEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text("settings.localModels.cloudPolish.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Text("settings.localModels.cloudPolish.subtitle") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) - } - } - .toggleStyle(.switch) - .tint(palette.accent) + Toggle(isOn: $config.localModeCloudPolishEnabled) { + Text("settings.localModels.cloudPolish.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) } + .toggleStyle(.switch) + .tint(palette.accent) .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.sm) - .frame(minHeight: SettingsListMetrics.doubleLineMinHeight) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // MARK: Helpers diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 4352e65..3c8b783 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -756,8 +756,36 @@ private struct APISetupPage: View { } .padding(.horizontal, Spacing.lg) } + + // v0.2.1: translation row lives on the onboarding engine + // page so first-time users can pick a target language + // before they ever see the keyboard. We render the same + // `TranslationPickerRow` used in the language tab — same + // persisted bindings, same "需云端" hint when local is + // active — wrapped in the section's surface card so it + // sits flush with the engine / provider cards above. + translationSection + .padding(.horizontal, Spacing.lg) } .padding(.bottom, Spacing.xxxl) } } + + private var translationSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + Text("settings.translation.title") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textSecondary) + .textCase(.uppercase) + .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 0) { + TranslationPickerRow(config: config) + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + } } diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index d208b93..30304e0 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -62,17 +62,16 @@ struct SettingsView: View { VStack(spacing: Spacing.md) { appLanguageSection engineSection + // v0.2.1: hide provider/api card when the + // local engine is active regardless of the + // cloud-polish toggle. Local mode is + // contractually ASR-only, so provider/model/ + // base URL/API key controls have no use — + // and exposing them invites the user to fill + // out a DeepSeek key they can't use. if config.engineMode == "cloud" { providerSection apiSection - } else if config.localModeCloudPolishEnabled { - // v0.2.0: local engine + cloud polish on. - // Surface the provider / API key fields so - // the user can fill in their DeepSeek key. - // We hide them when the toggle is off so the - // local engine stays genuinely local. - providerSection - apiSection } languageAndModelsSection if config.engineMode == "cloud" { @@ -128,10 +127,12 @@ struct SettingsView: View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.language.title") VStack(spacing: 0) { - if config.engineMode == "local" { - LocalModelsGroup(config: config) - Divider().background(palette.divider) - } + // v0.2.1: language tab reorder — ASR locale ("识别语言") + // now sits above the cloud-polish toggle / local models + // block so the row that maps to microphone input comes + // first, the row that maps to post-processing comes + // second, and translation (post-post-processing) sits at + // the bottom. LocalePickerRow( locales: effectiveLocales, selection: Binding( @@ -139,11 +140,10 @@ 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. + if config.engineMode == "local" { + Divider().background(palette.divider) + LocalModelsGroup(config: config) + } Divider().background(palette.divider) TranslationPickerRow(config: config) } diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index d503d57..16914e5 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -1,22 +1,23 @@ // 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. +// Single-row "翻译" picker — replaces the previous two-row toggle + +// target-locale dropdown. Lets the user pick "不翻译" (off, the +// default) or one of the 10 target languages, all from a single +// `Menu`. // -// 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). +// Mapping to persisted state: +// • "不翻译" → translationEnabled = false +// • any specific locale → translationEnabled = true, translationTargetLocaleId = // -// Reuses the host app's `ProviderConfig` `translationEnabled` / -// `translationTargetLocaleId` bindings — no new state, no new -// persistence path. +// The local engine constraint ("translation is cloud-only") is handled +// in two places that read this row: +// • The picker greys out specific locales (and prepends a "需云端" +// hint) when `config.isLocalEngine` — the user can still pick +// something but it won't fire end-to-end until they switch engines. +// • The pipeline (`PolishingService`) rejects `.translate` on local +// engine with `translationNotAvailable`, which `KeyboardViewController` +// surfaces as a 2.4s toast. Belt + suspenders. import SwiftUI import OSGKeyboardShared @@ -26,90 +27,87 @@ struct TranslationPickerRow: View { @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") + Text("settings.translation.title") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Spacer() Menu { ForEach(TranslationLanguageCatalog.all) { language in Button { - config.translationTargetLocaleId = language.id + apply(language) } label: { - if language.id == config.translationTargetLocaleId { - Label(language.nativeName, systemImage: "checkmark") + if currentSelectionId == language.id { + Label(displayLabel(for: language), systemImage: "checkmark") } else { - Text(language.nativeName) + Text(displayLabel(for: language)) } } } } label: { HStack(spacing: 6) { - Text(currentTargetName) + Text(currentLabel) .font(TypeStyle.body) .foregroundStyle(pickerForeground) + if config.isLocalEngine && !currentIsOff { + // Inline hint so the user knows the picker + // selection is being held but won't fire on the + // local engine. + Text("settings.translation.hint.needsCloud") + .font(TypeStyle.caption2) + .foregroundStyle(palette.warning) + } 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 + // MARK: - Selection plumbing + + /// Currently selected id derived from `translationEnabled`. We + /// route everything through the `translationEnabled` boolean so the + /// picker stays in lock-step with the rest of the system (chip, + /// pipeline, `isTranslationEffective`). + private var currentSelectionId: String { + config.translationEnabled + ? config.translationTargetLocaleId + : TranslationLanguageCatalog.offLocaleId + } + + private var currentLabel: String { + displayLabel(for: TranslationLanguageCatalog.resolve(currentSelectionId)) + } + + private var currentIsOff: Bool { + TranslationLanguageCatalog.isOff(currentSelectionId) + } + + private func displayLabel(for language: TranslationLanguage) -> String { + if language.id == TranslationLanguageCatalog.offLocaleId { + return AppL10n.string("settings.translation.off") + } + return language.nativeName } private var pickerForeground: Color { - config.isLocalEngine ? palette.textTertiary : palette.textSecondary + currentIsOff ? palette.textSecondary : palette.textPrimary + } + + /// Translates a picker choice into the underlying + /// `translationEnabled` + `translationTargetLocaleId` pair. Picking + /// "不翻译" clears the toggle; any locale flips it on and stores + /// the id. + private func apply(_ language: TranslationLanguage) { + if language.id == TranslationLanguageCatalog.offLocaleId { + config.translationEnabled = false + return + } + config.translationEnabled = true + config.translationTargetLocaleId = language.id } } \ No newline at end of file diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 8834ae7..27fd635 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -109,10 +109,9 @@ "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.translation.title" = "Translation"; +"settings.translation.off" = "Don't translate"; +"settings.translation.hint.needsCloud" = "Needs cloud"; "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 ef69f14..bb70274 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -109,10 +109,9 @@ "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.translation.title" = "翻译"; +"settings.translation.off" = "不翻译"; +"settings.translation.hint.needsCloud" = "需云端"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardShared/Models/TranslationLanguage.swift b/OSGKeyboardShared/Models/TranslationLanguage.swift index 1380c21..6768415 100644 --- a/OSGKeyboardShared/Models/TranslationLanguage.swift +++ b/OSGKeyboardShared/Models/TranslationLanguage.swift @@ -28,12 +28,22 @@ public struct TranslationLanguage: Identifiable, Hashable, Sendable { } public enum TranslationLanguageCatalog { - /// Default target language id used on fresh installs. + /// Sentinel id for "don't translate" — the default selection in the + /// picker. Picked over an `Optional` so the + /// single-row `Picker` binding stays a plain `String` (and the same + /// code path also works for the `TranslationChip` Menu). + public static let offLocaleId = "off" + /// Default target language id used on fresh installs when translation + /// is enabled. The picker still defaults to `offLocaleId` — this is + /// only the language we'd fall back to if a stale "on" state is + /// recovered without a remembered target. public static let defaultLocaleId = "en" /// Curated set. Order matters — the picker / chip render top-to- - /// bottom, and `defaultLocaleId` is the default selection. + /// bottom, with `offLocaleId` ("不翻译") at the very top so the + /// "turn off" action is one tap away from any enabled state. public static let all: [TranslationLanguage] = [ + TranslationLanguage(id: offLocaleId, promptLanguageName: "", nativeName: ""), TranslationLanguage(id: "en", promptLanguageName: "English", nativeName: "English"), TranslationLanguage(id: "zh-Hans", promptLanguageName: "Simplified Chinese", nativeName: "简体中文"), TranslationLanguage(id: "zh-Hant", promptLanguageName: "Traditional Chinese", nativeName: "繁體中文"), @@ -46,14 +56,23 @@ public enum TranslationLanguageCatalog { TranslationLanguage(id: "pt", promptLanguageName: "Portuguese", nativeName: "Português"), ] + /// True when the given id is the "off" sentinel. Used by the picker + /// to flip `translationEnabled` and by the pipeline to skip the + /// translate prompt. + public static func isOff(_ id: String) -> Bool { + id == offLocaleId + } + /// 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. + /// `offLocaleId` (the picker default) 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, and the picker lands on the safe "off" state + /// instead of an arbitrary language. 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] + return all.first { $0.id == offLocaleId } ?? all[0] } } \ No newline at end of file From 9b759281ccbeaf8d9a2182ed9991c2c5840b6aad Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 13:52:13 +0800 Subject: [PATCH 3/8] feat(translation-v2): refactor translationEnabled into computed + onboarding row visibility --- OSGKeyboard/Views/OnboardingView.swift | 37 ++++-- OSGKeyboard/Views/SettingsView.swift | 4 +- OSGKeyboard/Views/TranslationPickerRow.swift | 116 ++++++++---------- OSGKeyboard/en.lproj/Localizable.strings | 1 - OSGKeyboard/zh-Hans.lproj/Localizable.strings | 1 - OSGKeyboardExt/KeyboardViewController.swift | 23 ++-- .../Services/AppGroupPersistor.swift | 32 +++-- OSGKeyboardExt/Views/TranslationChip.swift | 61 +++++---- OSGKeyboardShared/Models/ProviderConfig.swift | 62 +++++++--- .../Services/AppGroupStore.swift | 47 ++++--- .../Services/KeyboardState.swift | 22 ++-- 11 files changed, 226 insertions(+), 180 deletions(-) diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 3c8b783..48bd139 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -734,6 +734,18 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) APISettingsCard(config: config) .padding(.horizontal, Spacing.lg) + // v0.2.1 follow-up: translation row lives on the + // onboarding engine page so first-time users can + // pick a target language before they ever see the + // keyboard. Wrapped in the section's surface card + // so it sits flush with the engine / provider cards + // above; visibility gated by `isTranslationRowVisible` + // so the local engine only shows it when cloud + // polish is also enabled. + if config.isTranslationRowVisible { + translationSection + .padding(.horizontal, Spacing.lg) + } } else { // v0.2.0: local engine is iOS `SpeechAnalyzer` only. // Surface the cloud-polish toggle and a one-line @@ -755,22 +767,25 @@ private struct APISetupPage: View { ) } .padding(.horizontal, Spacing.lg) + // v0.2.1 follow-up: same conditional for the local + // branch — the row only renders when the engine + // can actually run the cloud translate-and-polish + // step (i.e. cloud polish is opted in). + if config.isTranslationRowVisible { + translationSection + .padding(.horizontal, Spacing.lg) + } } - - // v0.2.1: translation row lives on the onboarding engine - // page so first-time users can pick a target language - // before they ever see the keyboard. We render the same - // `TranslationPickerRow` used in the language tab — same - // persisted bindings, same "需云端" hint when local is - // active — wrapped in the section's surface card so it - // sits flush with the engine / provider cards above. - translationSection - .padding(.horizontal, Spacing.lg) } .padding(.bottom, Spacing.xxxl) } } + /// v0.2.1 follow-up: extracted so both engine branches can render + /// the same surface card + picker. `TranslationPickerRow` itself + /// reads `ProviderConfig.translationTargetLocaleId` directly, so + /// picking a locale in onboarding flows through to the keyboard + /// extension on the next `load()` cycle. private var translationSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { Text("settings.translation.title") @@ -779,7 +794,7 @@ private struct APISetupPage: View { .textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { - TranslationPickerRow(config: config) + TranslationPickerRow(config: config, isVisible: true) } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 30304e0..ba7c7b0 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -145,7 +145,9 @@ struct SettingsView: View { LocalModelsGroup(config: config) } Divider().background(palette.divider) - TranslationPickerRow(config: config) + if config.isTranslationRowVisible { + TranslationPickerRow(config: config, isVisible: true) + } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index 16914e5..e953b67 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -6,18 +6,20 @@ // default) or one of the 10 target languages, all from a single // `Menu`. // -// Mapping to persisted state: -// • "不翻译" → translationEnabled = false -// • any specific locale → translationEnabled = true, translationTargetLocaleId = +// v0.2.1 follow-up: row is rendered through an `isVisible` parameter +// so callers (`SettingsView`, `OnboardingView`) can drop the row +// entirely when the engine can't run the cloud translate-and-polish +// step (`ProviderConfig.isTranslationRowVisible`). The "needs cloud" +// inline hint was deleted along with the previous Bool toggle — the +// user only sees the row when the engine can act on the choice. // -// The local engine constraint ("translation is cloud-only") is handled -// in two places that read this row: -// • The picker greys out specific locales (and prepends a "需云端" -// hint) when `config.isLocalEngine` — the user can still pick -// something but it won't fire end-to-end until they switch engines. -// • The pipeline (`PolishingService`) rejects `.translate` on local -// engine with `translationNotAvailable`, which `KeyboardViewController` -// surfaces as a 2.4s toast. Belt + suspenders. +// Mapping to persisted state: +// • "不翻译" → translationTargetLocaleId = "off" +// • any specific locale → translationTargetLocaleId = +// +// The pipeline (`PolishingService`) still rejects `.translate` on the +// local engine when cloud polish is off, which +// `KeyboardViewController` surfaces as a 2.4s toast. Belt + suspenders. import SwiftUI import OSGKeyboardShared @@ -26,57 +28,54 @@ struct TranslationPickerRow: View { @Environment(\.themePalette) private var palette: ThemePalette @ObservedObject var config: ProviderConfig + /// Visibility flag — when `false` the row renders as `EmptyView` + /// (callers can also wrap the call site in `if` for symmetry, but + /// having the guard here means a forgotten `if` still produces a + /// safe no-op rather than a leaked dead row). + var isVisible: Bool = true + var body: some View { - HStack { - Text("settings.translation.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - Spacer() - Menu { - ForEach(TranslationLanguageCatalog.all) { language in - Button { - apply(language) - } label: { - if currentSelectionId == language.id { - Label(displayLabel(for: language), systemImage: "checkmark") - } else { - Text(displayLabel(for: language)) + if isVisible { + HStack { + Text("settings.translation.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Menu { + ForEach(TranslationLanguageCatalog.all) { language in + Button { + apply(language) + } label: { + if currentSelectionId == language.id { + Label(displayLabel(for: language), systemImage: "checkmark") + } else { + Text(displayLabel(for: language)) + } } } - } - } label: { - HStack(spacing: 6) { - Text(currentLabel) - .font(TypeStyle.body) - .foregroundStyle(pickerForeground) - if config.isLocalEngine && !currentIsOff { - // Inline hint so the user knows the picker - // selection is being held but won't fire on the - // local engine. - Text("settings.translation.hint.needsCloud") - .font(TypeStyle.caption2) - .foregroundStyle(palette.warning) + } label: { + HStack(spacing: 6) { + Text(currentLabel) + .font(TypeStyle.body) + .foregroundStyle(currentIsOff ? palette.textSecondary : palette.textPrimary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(palette.textTertiary) } - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(palette.textTertiary) } } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } - .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // MARK: - Selection plumbing - /// Currently selected id derived from `translationEnabled`. We - /// route everything through the `translationEnabled` boolean so the - /// picker stays in lock-step with the rest of the system (chip, - /// pipeline, `isTranslationEffective`). + /// Currently selected id — the picker always reads + /// `translationTargetLocaleId` directly (the previous + /// `translationEnabled` boolean is now derived from it). private var currentSelectionId: String { - config.translationEnabled - ? config.translationTargetLocaleId - : TranslationLanguageCatalog.offLocaleId + config.translationTargetLocaleId } private var currentLabel: String { @@ -94,20 +93,11 @@ struct TranslationPickerRow: View { return language.nativeName } - private var pickerForeground: Color { - currentIsOff ? palette.textSecondary : palette.textPrimary - } - - /// Translates a picker choice into the underlying - /// `translationEnabled` + `translationTargetLocaleId` pair. Picking - /// "不翻译" clears the toggle; any locale flips it on and stores - /// the id. + /// Translates a picker choice into a single persisted field. + /// "不翻译" writes `offLocaleId`; any concrete locale writes its + /// id. `ProviderConfig.translationEnabled` is derived from the + /// resulting value, so callers don't need to flip a separate Bool. private func apply(_ language: TranslationLanguage) { - if language.id == TranslationLanguageCatalog.offLocaleId { - config.translationEnabled = false - return - } - config.translationEnabled = true config.translationTargetLocaleId = language.id } } \ No newline at end of file diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 27fd635..94fa21e 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -111,7 +111,6 @@ // v0.2.1: translation feature "settings.translation.title" = "Translation"; "settings.translation.off" = "Don't translate"; -"settings.translation.hint.needsCloud" = "Needs cloud"; "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 bb70274..921c35a 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -111,7 +111,6 @@ // v0.2.1: 翻译功能 "settings.translation.title" = "翻译"; "settings.translation.off" = "不翻译"; -"settings.translation.hint.needsCloud" = "需云端"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 9ef159b..9be2634 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -138,7 +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) } + // v0.2.1 follow-up: removed `setTranslationEnabled` — the chip + // / picker only writes the locale id now; `enabled` is derived. 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(" ") } @@ -645,20 +646,12 @@ public final class KeyboardViewController: UIInputViewController { // 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. + /// v0.2.1 follow-up: 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. Translation's + /// "on/off" state is now derived from this id (== `offLocaleId` + /// means off), so there's no separate toggle to persist. private func persistTranslationTargetLocaleId(_ id: String) { let resolved = TranslationLanguageCatalog.resolve(id).id state.translationTargetLocaleId = resolved diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 8eadc83..6d39e82 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -35,11 +35,10 @@ 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 + // v0.2.1 follow-up: only the target locale is persisted — + // `translationEnabled` is derived from it. Hydrate once at + // startup; `refreshRuntimeFlags` keeps the chip in sync while + // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId // v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that // into the State flags so downstream consumers see the same @@ -81,10 +80,8 @@ 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 + // v0.2.1 follow-up: same as `load` — only the locale is + // persisted, `enabled` is derived. 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 @@ -117,14 +114,15 @@ public struct AppGroupPersistor { 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"`). + /// v0.2.1: persist translation target locale id (e.g. `"en"`, + /// `"ja"`, or `TranslationLanguageCatalog.offLocaleId`). The + /// chip / picker call this through `KeyboardState.setTranslationTargetLocaleId`. + /// + /// v0.2.1 follow-up: removed `persist(translationEnabled:)` — the + /// enabled state is derived from the locale id, so callers only + /// need to write the locale. Keeping the legacy Bool overload + /// around would have implied that there's a separate on/off + /// switch to persist, which is no longer the model. public func persist(translationTargetLocaleId: String) { guard AppGroup.isAvailable else { return } AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId) diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index 4cf08c7..b1cab40 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -5,10 +5,17 @@ // top bar. Doubles as both the on/off switch and the target-language // picker — same Menu pattern as `LocaleChip` so muscle memory transfers. // +// v0.2.1 follow-up: removed the explicit on/off toggle entry. The +// chip is now a pure picker over the 11 catalog rows (off + 10 +// locales); selecting "不翻译" turns translation off, selecting any +// locale turns it on with that target. `translationEnabled` is +// derived from the locale id so the chip / pipeline read the same +// source of truth. +// // Visual states: -// • disabled → dim outline, "翻译" label -// • enabled + cloud → accent fill, "→ EN" / "→ 日本語" style label -// • enabled + local engine→ warning fill + "翻译需云端" hint (effectively +// • off → dim outline, "翻译" label +// • on + cloud → accent fill, "→ EN" / "→ 日本語" style label +// • on + local engine→ warning fill + "翻译需云端" hint (effectively // inert; the pipeline rejects the mode and the controller surfaces // the error toast) // @@ -26,28 +33,19 @@ struct TranslationChip: View { 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) - } + // v0.2.1 follow-up: pure picker over the full catalog, + // including `offLocaleId` at the top so "turn off" is one + // tap from any enabled state. Picking a row writes + // `translationTargetLocaleId`; `translationEnabled` is + // derived from it. + ForEach(TranslationLanguageCatalog.all) { language in + Button { + state.setTranslationTargetLocaleId(language.id) + } label: { + if language.id == currentSelectionId { + Label(displayLabel(for: language), systemImage: "checkmark") + } else { + Text(displayLabel(for: language)) } } } @@ -80,6 +78,19 @@ struct TranslationChip: View { .overlay(Capsule().stroke(stroke(enabled: enabled, isLocal: isLocal), lineWidth: 0.5)) } + /// Active selection id — the chip derives "on" from a non-off + /// locale id, so reading `translationTargetLocaleId` is enough. + private var currentSelectionId: String { + state.translationTargetLocaleId + } + + private func displayLabel(for language: TranslationLanguage) -> String { + if language.id == TranslationLanguageCatalog.offLocaleId { + return ExtL10n.string("keyboard.translation.off") + } + return language.nativeName + } + 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. diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index f85eb88..98c7c2c 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -39,11 +39,17 @@ 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 + // v0.2.1: optional translation step after ASR. 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" + // + // v0.2.1 follow-up: `config.translationEnabled` was *removed* + // as a persisted key — translation is now derived from + // `translationTargetLocaleId` (== offLocaleId means "off"). The + // store still tolerates legacy reads of the old key so users + // who upgraded from a build that wrote it don't see a flash of + // "on" state during init, but new writes never touch the key. static let translationTargetLocaleId = "config.translationTargetLocaleId" } @@ -122,16 +128,23 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { 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) } + /// `translationTargetLocaleId` before insertion. **Derived** — + /// translation is on iff the user has selected a target locale + /// (i.e. the persisted id is anything other than + /// `TranslationLanguageCatalog.offLocaleId`). Default off. + /// + /// This used to be a stored `@Published var ... { didSet }` but the + /// chip / picker now writes the locale directly; collapsing the + /// pair into one field removes the "two writes out of sync" bug + /// surface entirely. + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } /// 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`. + /// translate-and-polish prompt should produce. Default `"off"` — + /// translation is opt-in. 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). @Published public var translationTargetLocaleId: String { didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) } } @@ -145,6 +158,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { translationEnabled && engineMode == "cloud" } + /// v0.2.1: row visibility predicate. Translation is shown only when + /// the engine can actually run the cloud translate-and-polish step: + /// - cloud engine: always visible + /// - local engine: visible only when cloud polish is also enabled + /// (otherwise translation is silently inert — the local engine + /// rejects `.translate` upstream, so we'd be advertising a + /// feature that can't run). + public var isTranslationRowVisible: Bool { + (engineMode == "cloud") || (engineMode == "local" && localModeCloudPolishEnabled) + } + public var isConfigured: Bool { // Local engine (on-device ASR only) doesn't need an API key, // base URL, or model — the LLM round-trip is skipped entirely. @@ -222,15 +246,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" + // v0.2.1 follow-up: `translationEnabled` is now derived from + // `translationTargetLocaleId` — no separate init read. + // Default the locale id to `offLocaleId` so existing installs + // that never picked a target language stay in the "off" state + // (the previous build's default of `"en"` would silently turn + // translation on for every upgraded user; off is the safe + // conservative default that matches the picker / chip UX). + self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) + ?? TranslationLanguageCatalog.offLocaleId // Cloud no longer exposes off/transcribe; migrate legacy values. if self.engineMode == "cloud", self.modeId != "polish" { diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 0f5918e..635a463 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -39,8 +39,11 @@ 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" + // v0.2.1 follow-up: `config.translationEnabled` was *removed* as a + // persisted key — translation is derived from the target locale + // id. New code should only write/read `translationTargetLocaleId`; + // the `translationEnabled` Bool accessor below is kept as a + // computed shim for source compatibility. static let translationTargetLocaleId = "config.translationTargetLocaleId" } @@ -107,20 +110,21 @@ 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. + /// v0.2.1 follow-up: derived — translation is on iff a target locale + /// has been selected. The `translationTargetLocaleId` getter below + /// is the source of truth; this property exists for backwards + /// compatibility with call sites that read `store.translationEnabled`. public var translationEnabled: Bool { - guard defaults.object(forKey: Key.translationEnabled) != nil else { - return false - } - return defaults.bool(forKey: Key.translationEnabled) + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId } /// v0.2.1: target locale id the translate-and-polish prompt should - /// produce (e.g. `"en"`). Defaults to `"en"` when nothing is stored. + /// produce (e.g. `"en"`, `"ja"`). Defaults to `offLocaleId` ("off") + /// when nothing is stored, matching the picker / chip UX where the + /// user has to actively pick a language to turn translation on. public var translationTargetLocaleId: String { - defaults.string(forKey: Key.translationTargetLocaleId) ?? "en" + defaults.string(forKey: Key.translationTargetLocaleId) + ?? TranslationLanguageCatalog.offLocaleId } // MARK: - Writes @@ -145,15 +149,24 @@ 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. + /// v0.2.1 follow-up: kept for source compatibility with callers that + /// still pass a Bool (e.g. older tests, any leftover bridge code). + /// `enabled == true` selects `defaultLocaleId` ("en") as a sensible + /// on-ramp target; `enabled == false` resets to `offLocaleId`. + /// The keyboard chip / pipeline now write the locale id directly + /// via `setTranslationTargetLocaleId`, which is the preferred path. public func setTranslationEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: Key.translationEnabled) + defaults.set( + enabled ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId, + forKey: Key.translationTargetLocaleId + ) } - /// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`). Same - /// read cadence as `setTranslationEnabled`. + /// v0.2.1: persist target locale id (e.g. `"en"`, `"ja"`, or + /// `TranslationLanguageCatalog.offLocaleId`). 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 setTranslationTargetLocaleId(_ id: String) { defaults.set(id, forKey: Key.translationTargetLocaleId) } diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index a49d897..99c5365 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -87,13 +87,17 @@ 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 follow-up: derived — translation is on iff a target + /// locale has been selected (mirrors `ProviderConfig.translationEnabled` + /// so the chip / pipeline read the same source of truth). + public var translationEnabled: Bool { + translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId + } /// 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 + /// Defaults to `offLocaleId` so the keyboard boots in the "off" + /// state on first install. + @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId /// 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 @@ -115,11 +119,9 @@ 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`. + /// v0.2.1 follow-up: only the locale picker remains — `enabled` + /// is derived from the locale id, so there's no separate toggle to + /// persist. Wired in `KeyboardViewController.installStateActions`. public var setTranslationTargetLocaleId: (String) -> Void = { _ in } public var insertNewline: () -> Void = {} public var insertSpace: () -> Void = {} From 0956bb8534e08b8a12b2dfd9aaa632ce0e646749 Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 14:25:06 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(translation-v2):=20final=20review=20?= =?UTF-8?q?=E2=80=94=20dual-engine=20translation=20row=20+=20DeepSeek=20po?= =?UTF-8?q?lish=20override=20+=20dead=20code=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Views/LocalEngineSettingsRows.swift | 30 +++++--- OSGKeyboard/Views/OnboardingView.swift | 24 +++--- OSGKeyboard/Views/TranslationPickerRow.swift | 13 +++- OSGKeyboard/en.lproj/Localizable.strings | 3 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 3 +- OSGKeyboardExt/KeyboardViewController.swift | 35 ++++----- OSGKeyboardExt/Views/TranslationChip.swift | 30 +++++--- OSGKeyboardExt/en.lproj/Keyboard.strings | 2 - OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 2 - OSGKeyboardShared/Models/ProviderConfig.swift | 36 +++++---- .../Services/KeyboardState.swift | 12 +-- .../Services/PolishingService.swift | 75 +++++++++++++------ 12 files changed, 163 insertions(+), 102 deletions(-) diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index f5632e1..dcd4c6d 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -58,20 +58,28 @@ struct LocalModelsGroup: View { /// key yet), but the polish call short-circuits with an Alert if /// the Keychain is empty when it fires. /// - /// v0.2.1: dropped the long descriptive subtitle — it explained - /// things the user could read about elsewhere in Settings - /// (provider / API key section) and made the row visually heavy. - /// Title + switch is enough; details live in `CloudPolishDisclosureBanner`. + /// v0.2.1 follow-up: added a one-line caption under the toggle + /// that names the default cloud vendor (DeepSeek) so the user + /// knows where the transcript is going when they flip the switch. + /// The toggle row is now a two-line layout — title + caption — + /// so we drop the explicit `singleLineMinHeight` here and let + /// `SettingsListMetrics` provide enough vertical room. private var cloudPolishRow: some View { - Toggle(isOn: $config.localModeCloudPolishEnabled) { - Text("settings.localModels.cloudPolish.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $config.localModeCloudPolishEnabled) { + Text("settings.localModels.cloudPolish.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + } + .toggleStyle(.switch) + .tint(palette.accent) + Text("settings.localModels.cloudPolish.caption") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } - .toggleStyle(.switch) - .tint(palette.accent) .padding(.horizontal, Spacing.md) - .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + .padding(.vertical, Spacing.xs) } // MARK: Helpers diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 48bd139..183944a 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -737,11 +737,11 @@ private struct APISetupPage: View { // v0.2.1 follow-up: translation row lives on the // onboarding engine page so first-time users can // pick a target language before they ever see the - // keyboard. Wrapped in the section's surface card - // so it sits flush with the engine / provider cards - // above; visibility gated by `isTranslationRowVisible` - // so the local engine only shows it when cloud - // polish is also enabled. + // keyboard. v0.2.1 final review: both engines now + // show the row (the local engine routes the polish + // step through DeepSeek, so the constraint is gone). + // Wrapped in the same surface card chrome as the + // APISettingsCard above for visual symmetry. if config.isTranslationRowVisible { translationSection .padding(.horizontal, Spacing.lg) @@ -767,10 +767,9 @@ private struct APISetupPage: View { ) } .padding(.horizontal, Spacing.lg) - // v0.2.1 follow-up: same conditional for the local - // branch — the row only renders when the engine - // can actually run the cloud translate-and-polish - // step (i.e. cloud polish is opted in). + // v0.2.1 final review: same surface card chrome as + // the cloud branch — the row now renders for both + // engines. if config.isTranslationRowVisible { translationSection .padding(.horizontal, Spacing.lg) @@ -786,9 +785,14 @@ private struct APISetupPage: View { /// reads `ProviderConfig.translationTargetLocaleId` directly, so /// picking a locale in onboarding flows through to the keyboard /// extension on the next `load()` cycle. + /// + /// v0.2.1 final review: the section header now reads + /// `settings.translation.afterPolish` (renamed alongside the row + /// title in `TranslationPickerRow`) so the section and row read + /// as one cohesive group. private var translationSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - Text("settings.translation.title") + Text("settings.translation.afterPolish") .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) .textCase(.uppercase) diff --git a/OSGKeyboard/Views/TranslationPickerRow.swift b/OSGKeyboard/Views/TranslationPickerRow.swift index e953b67..f37abcc 100644 --- a/OSGKeyboard/Views/TranslationPickerRow.swift +++ b/OSGKeyboard/Views/TranslationPickerRow.swift @@ -13,13 +13,18 @@ // inline hint was deleted along with the previous Bool toggle — the // user only sees the row when the engine can act on the choice. // +// v0.2.1 final review: both engines now run the translate-and-polish +// step (the local engine routes through DeepSeek via +// `ProviderConfig.localModeProviderId`), so the row title changed +// from "Translation" to "Polish then translate" to match the new +// always-on translation contract. +// // Mapping to persisted state: // • "不翻译" → translationTargetLocaleId = "off" // • any specific locale → translationTargetLocaleId = // -// The pipeline (`PolishingService`) still rejects `.translate` on the -// local engine when cloud polish is off, which -// `KeyboardViewController` surfaces as a 2.4s toast. Belt + suspenders. +// The pipeline (`PolishingService`) honors `.translate` on both +// engines when this row is visible — no more "rejected mode" toast. import SwiftUI import OSGKeyboardShared @@ -37,7 +42,7 @@ struct TranslationPickerRow: View { var body: some View { if isVisible { HStack { - Text("settings.translation.title") + Text("settings.translation.afterPolish") .font(TypeStyle.body) .foregroundStyle(palette.textPrimary) Spacer() diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 94fa21e..2e005d4 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -109,7 +109,7 @@ "settings.api.title" = "API"; "settings.language.title" = "Language"; // v0.2.1: translation feature -"settings.translation.title" = "Translation"; +"settings.translation.afterPolish" = "Polish then translate"; "settings.translation.off" = "Don't translate"; "settings.languageModels.title" = "Language & models"; "settings.localModels.title" = "On-device models"; @@ -119,6 +119,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld ready"; "settings.localModels.cloudPolish.title" = "Cloud polish after ASR"; "settings.localModels.cloudPolish.subtitle" = "Sends the transcript to your configured cloud LLM (DeepSeek by default) for cleanup. Enable only when iOS speech recognition struggles — noisy far-field audio, strong accents, etc. Requires a DeepSeek API key."; +"settings.localModels.cloudPolish.caption" = "Uses DeepSeek to polish transcripts in local mode."; "settings.language.subtitle.cloud" = "Recognition language and text processing mode."; "settings.language.subtitle.local" = "Recognition language."; "settings.mode.title" = "Mode"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 921c35a..91291ed 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -109,7 +109,7 @@ "settings.api.title" = "接口"; "settings.language.title" = "语言"; // v0.2.1: 翻译功能 -"settings.translation.title" = "翻译"; +"settings.translation.afterPolish" = "润色后翻译"; "settings.translation.off" = "不翻译"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; @@ -119,6 +119,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪"; "settings.localModels.cloudPolish.title" = "识别后云端润色"; "settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。"; +"settings.localModels.cloudPolish.caption" = "本地模式下,使用 DeepSeek 进行云端润色。"; "settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; "settings.language.subtitle.local" = "选择识别语言。"; "settings.mode.title" = "模式"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 9be2634..4688074 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -533,14 +533,28 @@ public final class KeyboardViewController: UIInputViewController { 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. + // already sent. `isTranslationEffective` no longer gates on + // `engineMode == "cloud"` — the row visibility predicate + // already keeps the picker honest, and the local engine's + // translate-and-polish path now routes through DeepSeek. let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective ? .translate(targetLocaleId: self.state.translationTargetLocaleId) : .polish + // v0.2.1: local engine routes through DeepSeek for the + // polish / translate step regardless of the user's chosen + // cloud provider — DeepSeek is cheap and strong on + // Chinese, which is the dominant input for the on-device + // ASR transcript. Cloud engine honors the user's own + // provider id by passing `nil`. + let overrideProviderId: String? = self.state.engineMode == "local" + ? "deepseek" + : nil do { - let polished = try await self.polisher.polish(trimmed, mode: polishMode) + let polished = try await self.polisher.polish( + trimmed, + mode: polishMode, + providerIdOverride: overrideProviderId + ) self.textDocumentProxy.insertText(polished) self.state.lastTranscript = "" self.state.phase = .idle @@ -588,19 +602,6 @@ 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 diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index b1cab40..0fb8947 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -12,12 +12,15 @@ // derived from the locale id so the chip / pipeline read the same // source of truth. // +// v0.2.1 final review: dropped the "needs cloud" warning state — +// both engines now run the translate-and-polish step (the local +// engine routes through DeepSeek via +// `ProviderConfig.localModeProviderId`). The chip is therefore just +// off / on, with the same accent treatment either way. +// // Visual states: // • off → dim outline, "翻译" label -// • on + cloud → accent fill, "→ EN" / "→ 日本語" style label -// • on + local engine→ warning fill + "翻译需云端" hint (effectively -// inert; the pipeline rejects the mode and the controller surfaces -// the error toast) +// • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label // // Stays in the same visual family as `CloudEngineChip` / `LocaleChip` // (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar @@ -92,11 +95,10 @@ struct TranslationChip: View { } 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") - } + // v0.2.1 follow-up: with the local engine now routing the + // polish / translate step through DeepSeek, the chip shows + // the same "→EN"-style label on both engines. There's no + // "needs cloud" hint path anymore. if !enabled { return ExtL10n.string("keyboard.translation.off") } @@ -124,19 +126,23 @@ struct TranslationChip: View { } private func foreground(enabled: Bool, isLocal: Bool) -> Color { - if enabled, isLocal { return palette.warning } + // v0.2.1 follow-up: the chip no longer needs a "warning" path + // for local + on — both engines share the accent treatment + // now. `isLocal` is kept in the signature so callers don't + // need to change; it's intentionally unused below. + _ = isLocal 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) } + _ = isLocal 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) } + _ = isLocal if enabled { return palette.accent.opacity(0.35) } return palette.divider } diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 40fe44d..5859671 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -181,10 +181,8 @@ "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"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index 4dfa80f..726eca0 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -181,10 +181,8 @@ "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" = "关闭"; diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 98c7c2c..6541110 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -149,24 +149,22 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { 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. + /// v0.2.1 follow-up: with the local engine's translate-and-polish + /// path now real (see `localModeProviderId`), `translationEnabled` + /// alone is enough to decide whether the pipeline should translate. + /// Row visibility (`isTranslationRowVisible`) already gates the UI + /// on engines that can actually run the step, so we don't need to + /// re-check `engineMode` here. public var isTranslationEffective: Bool { - translationEnabled && engineMode == "cloud" + translationEnabled } - /// v0.2.1: row visibility predicate. Translation is shown only when - /// the engine can actually run the cloud translate-and-polish step: - /// - cloud engine: always visible - /// - local engine: visible only when cloud polish is also enabled - /// (otherwise translation is silently inert — the local engine - /// rejects `.translate` upstream, so we'd be advertising a - /// feature that can't run). + /// v0.2.1 follow-up: row visibility predicate. Both engines can + /// now run the cloud translate-and-polish step (the local engine + /// routes through DeepSeek via `localModeProviderId`), so the row + /// is shown whenever an engine mode is selected. public var isTranslationRowVisible: Bool { - (engineMode == "cloud") || (engineMode == "local" && localModeCloudPolishEnabled) + engineMode == "local" || engineMode == "cloud" } public var isConfigured: Bool { @@ -197,6 +195,16 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { isLocalEngine && localModeCloudPolishEnabled } + /// v0.2.1 follow-up: when the local engine is using the cloud- + /// polish step, route the call through DeepSeek — cheap, strong + /// on Chinese, and the right default for the on-device ASR + /// transcript. Other engines honor the user's configured + /// `providerId` unchanged so cloud users keep their preferred + /// vendor (OpenAI / Anthropic / Zhipu / etc). + public var localModeProviderId: String { + isLocalEngine ? "deepseek" : providerId + } + /// The system prompt the user *sees* in the editor — fall back to the /// provider-aware default from `AppGroupStore` when nothing is set. public var defaultSystemPrompt: String { diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 99c5365..6669952 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -98,12 +98,14 @@ public final class KeyboardState: ObservableObject { /// Defaults to `offLocaleId` so the keyboard boots in the "off" /// state on first install. @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId - /// 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. + /// v0.2.1: effective predicate — mirrors `ProviderConfig`. + /// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"` + /// because the local engine's translate-and-polish step now runs + /// (routed through DeepSeek). Row visibility (`isTranslationRowVisible` + /// on `ProviderConfig`) keeps the picker honest, so the keyboard + /// can rely on `translationEnabled` alone here. public var isTranslationEffective: Bool { - translationEnabled && engineMode == "cloud" + translationEnabled } /// Convenience shorthand used by the pipeline and views. diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 720a57a..4ed40b4 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -29,10 +29,6 @@ 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 @@ -65,20 +61,19 @@ public actor PolishingService { self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1) } - public func polish(_ raw: String, mode: PolishMode = .polish) async throws -> String { + /// v0.2.1 follow-up: `providerIdOverride` lets callers pin the + /// remote polish step to a specific provider (the local engine + /// pins to DeepSeek regardless of the user's chosen cloud + /// provider). Pass `nil` to honor `store.providerId` as before. + public func polish( + _ raw: String, + mode: PolishMode = .polish, + systemPrompt: String? = nil, + providerIdOverride: String? = nil + ) 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 @@ -89,15 +84,44 @@ public actor PolishingService { guard !store.apiKey.isEmpty else { throw PolishError.missingAPIKey } - return try await polishRemote(trimmed, mode: mode) + return try await polishRemote( + trimmed, + mode: mode, + systemPrompt: systemPrompt, + providerIdOverride: providerIdOverride + ) } - return try await polishRemote(trimmed, mode: mode) + return try await polishRemote( + trimmed, + mode: mode, + systemPrompt: systemPrompt, + providerIdOverride: providerIdOverride + ) } - private func polishRemote(_ trimmed: String, mode: PolishMode) async throws -> String { - let client = injectedClient ?? store.makeClient() - let prompt = resolvedSystemPrompt(for: mode) + private func polishRemote( + _ trimmed: String, + mode: PolishMode, + systemPrompt: String? = nil, + providerIdOverride: String? = nil + ) async throws -> String { + // v0.2.1 follow-up: when the caller pins a provider id (the + // local engine pins DeepSeek) we still want to honor the + // injected test client, but we have to re-derive the + // preset/baseURL/model triplet from the *override* so the + // injected client gets the right values when it's nil. + let effectiveProviderId = providerIdOverride ?? store.providerId + let client: LLMClient + if let injectedClient { + client = injectedClient + } else { + let preset = LLMProvider.provider(id: effectiveProviderId) + let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL + let model = store.model.isEmpty ? preset.defaultModel : preset.defaultModel + client = OpenAICompatibleClient(baseURL: baseURL, apiKey: store.apiKey, model: model) + } + let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt) let budget = effectiveTimeout(for: trimmed) return try await withThrowingTaskGroup(of: String.self) { group in @@ -118,14 +142,19 @@ public actor PolishingService { /// 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 { + /// is byte-identical to before. An explicit `override` wins over + /// both paths so callers (and tests) can pin a specific prompt. + private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String { + if let override, !override.isEmpty { + return override + } switch mode { case .polish: return store.systemPrompt case .translate(let targetLocaleId): let target = TranslationLanguageCatalog.resolve(targetLocaleId) - return TranslationPrompt.make(target: target, providerId: store.providerId) + let pid = store.providerId + return TranslationPrompt.make(target: target, providerId: pid) } } From 93b6aa6c02ebe0fe443e7f6aacc8d4cc6f0f4d5e Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 15:16:04 +0800 Subject: [PATCH 5/8] feat(translation-polish): dual-engine translation UX + provider picker filter + topbar cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LLMProvider.isUserSelectable (default true) and filter ProviderPickerSection on it; next pass can hide non-user presets (e.g. a future DeepSeek key-preset) without changing call sites. - KeyboardRootView: hide TranslationChip when off (matches user's mental model of an opt-in feature), drop the 'warming' branch (Qwen3 download UX was removed with the backend in v0.2.0), unify chip pill height to minHeight 28 + vertical 6 for visual rhythm across all topbar chips. - TranslationChip: drop isLocal warning path — both engines now run the translate-and-polish step (local routes through DeepSeek via ProviderConfig.localModeProviderId). - OnboardingView: cloud engine branch now wraps the translation row in the same surface card chrome as the local branch. - Strings: drop keyboard.models.warming (no longer referenced). DeepSeek key pre-fill deferred to a follow-up. --- OSGKeyboard/Views/OnboardingView.swift | 5 +++ OSGKeyboard/Views/ProviderPickerSection.swift | 9 +++-- OSGKeyboardExt/Views/KeyboardRootView.swift | 29 ++++++++-------- OSGKeyboardExt/Views/TranslationChip.swift | 34 ++++++------------- OSGKeyboardExt/en.lproj/Keyboard.strings | 1 - OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 1 - OSGKeyboardShared/Models/LLMProvider.swift | 10 +++++- 7 files changed, 46 insertions(+), 43 deletions(-) diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 183944a..ce115f6 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -742,6 +742,11 @@ private struct APISetupPage: View { // step through DeepSeek, so the constraint is gone). // Wrapped in the same surface card chrome as the // APISettingsCard above for visual symmetry. + // + // v0.2.1 final review (topbar cleanup pass): the + // surface card chrome is owned by `translationSection` + // itself, so both branches get it for free — no + // per-branch duplication. if config.isTranslationRowVisible { translationSection .padding(.horizontal, Spacing.lg) diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index 4a0be00..fbbff9a 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -10,15 +10,20 @@ struct ProviderPickerSection: View { @ObservedObject var config: ProviderConfig var body: some View { + // v0.2.1 follow-up: filter out presets marked as + // `isUserSelectable == false` so a future "DeepSeek key + // pre-fill" preset (or similar) can ship in `presets` without + // showing up in the picker. + let visiblePresets = LLMProvider.presets.filter { $0.isUserSelectable } VStack(spacing: 0) { - ForEach(Array(LLMProvider.presets.enumerated()), id: \.element.id) { index, provider in + ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in Button { select(provider) } label: { row(provider, selected: provider.id == config.providerId) } .buttonStyle(.plain) - if index < LLMProvider.presets.count - 1 { + if index < visiblePresets.count - 1 { Divider().background(palette.divider) } } diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 230a1e7..5bc287f 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -80,7 +80,13 @@ public struct KeyboardRootView: View { // 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) + // v0.2.1 final review: only render the chip when translation + // is actually on. Off-by-default keeps the top bar compact + // for users who don't need translation; the menu still lives + // in onboarding so the feature is discoverable. + if state.translationEnabled { + TranslationChip(state: state) + } Spacer(minLength: 0) StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported) Button(action: state.openSettings) { @@ -231,13 +237,6 @@ private struct TranscriptLine: View { } .buttonStyle(.plain) .accessibilityHint(ExtL10n.text("keyboard.models.downloadHint")) - } else if isLocalEngine, localModelsReady, !localModelsLoaded { - HStack(spacing: 6) { - ProgressView().controlSize(.mini).tint(palette.textSecondary) - ExtL10n.text("keyboard.models.warming") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } } else if flowSessionActive { ExtL10n.text("keyboard.placeholder.idle") .font(TypeStyle.caption) @@ -418,7 +417,7 @@ private struct StatusBadge: View { .foregroundStyle(palette.textSecondary) } .padding(.horizontal, Spacing.xs) - .padding(.vertical, 3) + .padding(.vertical, 4) .background(palette.surface, in: Capsule()) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) } @@ -437,8 +436,8 @@ private struct CloudEngineChip: View { .font(TypeStyle.caption2) .foregroundStyle(palette.accent) .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 5) - .frame(minHeight: 26) + .padding(.vertical, 6) + .frame(minHeight: 28) .background(palette.accent.opacity(0.15), in: Capsule()) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) } @@ -457,8 +456,8 @@ private struct LocalEngineChip: View { .font(TypeStyle.caption2) .foregroundStyle(palette.accent) .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 5) - .frame(minHeight: 26) + .padding(.vertical, 6) + .frame(minHeight: 28) .background(palette.accent.opacity(0.15), in: Capsule()) .overlay(Capsule().stroke(palette.accent.opacity(0.35), lineWidth: 0.5)) } @@ -504,8 +503,8 @@ private struct LocaleChip: View { .font(TypeStyle.caption2) .foregroundStyle(palette.textPrimary) .padding(.horizontal, Spacing.xs + 2) - .padding(.vertical, 5) - .frame(minHeight: 26) + .padding(.vertical, 6) + .frame(minHeight: 28) .background(palette.surfaceElevated, in: Capsule()) .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) } diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index 0fb8947..7d45cad 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -23,7 +23,7 @@ // • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label // // Stays in the same visual family as `CloudEngineChip` / `LocaleChip` -// (Capsule + 26 pt min height + 5 pt vertical padding) so the top bar +// (Capsule + 28 pt min height + 6 pt vertical padding) so the top bar // doesn't grow when translation is enabled. import SwiftUI @@ -63,22 +63,21 @@ struct TranslationChip: View { @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)) + Text(chipLabel(target: target, enabled: enabled)) Image(systemName: "chevron.down") .font(.system(size: 8, weight: .bold)) } .font(TypeStyle.caption2) - .foregroundStyle(foreground(enabled: enabled, isLocal: isLocal)) + .foregroundStyle(foreground(enabled: enabled)) .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)) + .padding(.vertical, 6) + .frame(minHeight: 28) + .background(background(enabled: enabled), in: Capsule()) + .overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5)) } /// Active selection id — the chip derives "on" from a non-off @@ -94,11 +93,7 @@ struct TranslationChip: View { return language.nativeName } - private func chipLabel(target: TranslationLanguage, enabled: Bool, isLocal: Bool) -> String { - // v0.2.1 follow-up: with the local engine now routing the - // polish / translate step through DeepSeek, the chip shows - // the same "→EN"-style label on both engines. There's no - // "needs cloud" hint path anymore. + private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String { if !enabled { return ExtL10n.string("keyboard.translation.off") } @@ -125,24 +120,17 @@ struct TranslationChip: View { } } - private func foreground(enabled: Bool, isLocal: Bool) -> Color { - // v0.2.1 follow-up: the chip no longer needs a "warning" path - // for local + on — both engines share the accent treatment - // now. `isLocal` is kept in the signature so callers don't - // need to change; it's intentionally unused below. - _ = isLocal + private func foreground(enabled: Bool) -> Color { if enabled { return palette.accent } return palette.textPrimary } - private func background(enabled: Bool, isLocal: Bool) -> Color { - _ = isLocal + private func background(enabled: Bool) -> Color { if enabled { return palette.accent.opacity(0.15) } return palette.surfaceElevated } - private func stroke(enabled: Bool, isLocal: Bool) -> Color { - _ = isLocal + private func stroke(enabled: Bool) -> Color { if enabled { return palette.accent.opacity(0.35) } return palette.divider } diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 5859671..a288ad1 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -121,7 +121,6 @@ "keyboard.placeholder.cloudBadge" = "Cloud"; "keyboard.models.notDownloaded" = "On-device models not downloaded"; "keyboard.models.downloadHint" = "Open OSGKeyboard to download models"; -"keyboard.models.warming" = "Loading models…"; "keyboard.rec" = "REC"; "keyboard.space" = "Space"; "keyboard.denied.mic" = "Mic denied"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index 726eca0..c74a1e1 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -121,7 +121,6 @@ "keyboard.placeholder.cloudBadge" = "云端"; "keyboard.models.notDownloaded" = "本地模型尚未下载"; "keyboard.models.downloadHint" = "打开 OSGKeyboard 下载模型"; -"keyboard.models.warming" = "正在加载模型…"; "keyboard.rec" = "REC"; "keyboard.space" = "空格"; "keyboard.denied.mic" = "麦克风被拒绝"; diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift index 53fe351..7e6fcdd 100644 --- a/OSGKeyboardShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -14,6 +14,12 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { public let apiKeyURL: URL? /// Optional short blurb shown under the provider name in the picker. public let blurb: String? + /// Whether this preset should appear in user-facing provider pickers + /// (settings / onboarding). Defaults to `true` so the existing + /// `presets` array keeps its public surface area; future passes can + /// mark e.g. a DeepSeek key-pre-fill preset as `false` to hide it + /// from the picker without touching call sites. + public let isUserSelectable: Bool public init( id: String, @@ -21,7 +27,8 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { defaultBaseURL: String, defaultModel: String, apiKeyURL: URL? = nil, - blurb: String? = nil + blurb: String? = nil, + isUserSelectable: Bool = true ) { self.id = id self.name = name @@ -29,6 +36,7 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable { self.defaultModel = defaultModel self.apiKeyURL = apiKeyURL self.blurb = blurb + self.isUserSelectable = isUserSelectable } public static let presets: [LLMProvider] = [ From 4fec0da7f04b7457f0cd2b71693f4923791c8ad1 Mon Sep 17 00:00:00 2001 From: Rocky Date: Thu, 25 Jun 2026 17:34:02 +0800 Subject: [PATCH 6/8] feat(translation-polish-2): local-engine dedicated section + PreconfiguredKeys for DeepSeek MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PreconfiguredKeys.swift: placeholder constant for the DeepSeek API key the local engine uses. DEBUG build asserts at launch when the placeholder is still in place so nobody ships an always-401 build by accident. Replace TODO_FILL_LATER_DEEPSEEK_KEY before distributing. - PolishingService.polishRemote: * Adopted (A) path: when effective provider is DeepSeek, take the apiKey from PreconfiguredKeys.deepseek instead of store.apiKey. Refuse the round-trip with PolishError.missingAPIKey when the placeholder is still in place. * Adopted fix for pre-existing typo: store.model.isEmpty ? preset.defaultModel : store.model (the right-hand side was defaultModel on both branches, silently ignoring the user's custom model field). - LocalEngineSettingsRows.LocalModelsGroup: * Dropped the inline 'uses DeepSeek' caption — it added visual weight without telling the user anything they couldn't infer. * Translation row now lives inside the group so the local engine reads as one cohesive card. * Group owns its surface card chrome (palette.surface + rounded border) — callers no longer need to wrap it externally. - SettingsView: * languageAndModelsSection: dropped the trailing TranslationPickerRow and the inner LocalModelsGroup (now lifted to its own section). * New localEngineSettingsSection: renders only when engineMode == 'local', wrapping LocalModelsGroup with a 'settings.localEngine.title' header. * body: added the new section between languageAndModelsSection and the cloud-only systemPromptLinkSection. - Localizable.strings (en + zh-Hans): * Removed settings.localModels.cloudPolish.caption (no references). * Added settings.localEngine.title = 'Local engine' / '本地引擎'. DeepSeek key pre-fill tripwire + local-engine UI cohesion. No main merge, no PR. --- .../Views/LocalEngineSettingsRows.swift | 45 ++++++++++------- OSGKeyboard/Views/SettingsView.swift | 29 +++++++---- OSGKeyboard/en.lproj/Localizable.strings | 2 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 2 +- .../Services/PolishingService.swift | 25 ++++++++-- .../Services/PreconfiguredKeys.swift | 50 +++++++++++++++++++ 6 files changed, 118 insertions(+), 35 deletions(-) create mode 100644 OSGKeyboardShared/Services/PreconfiguredKeys.swift diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index dcd4c6d..946b808 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -27,11 +27,26 @@ struct LocalModelsGroup: View { @ObservedObject var config: ProviderConfig var body: some View { + // v0.2.1 follow-up: the LocalEngineGroup now owns the + // translation row so the local-engine Settings tab reads as + // one cohesive card. The same surface chrome + // (`palette.surface` + rounded border) the cloud branch uses + // on its own card wraps the whole group so it sits flush with + // the language tab above. VStack(spacing: 0) { speechRow Divider().background(palette.divider) cloudPolishRow + if config.isTranslationRowVisible { + Divider().background(palette.divider) + TranslationPickerRow(config: config, isVisible: true) + } } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.large, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) } // MARK: Speech row @@ -58,28 +73,20 @@ struct LocalModelsGroup: View { /// key yet), but the polish call short-circuits with an Alert if /// the Keychain is empty when it fires. /// - /// v0.2.1 follow-up: added a one-line caption under the toggle - /// that names the default cloud vendor (DeepSeek) so the user - /// knows where the transcript is going when they flip the switch. - /// The toggle row is now a two-line layout — title + caption — - /// so we drop the explicit `singleLineMinHeight` here and let - /// `SettingsListMetrics` provide enough vertical room. + /// v0.2.1 follow-up: dropped the inline "uses DeepSeek" caption + /// (the user already opted into cloud mode by switching engines, + /// and the vendor name surfaces when they tap the row's helper + /// text in onboarding / deep links). Title + switch is enough. private var cloudPolishRow: some View { - VStack(alignment: .leading, spacing: 4) { - Toggle(isOn: $config.localModeCloudPolishEnabled) { - Text("settings.localModels.cloudPolish.title") - .font(TypeStyle.body) - .foregroundStyle(palette.textPrimary) - } - .toggleStyle(.switch) - .tint(palette.accent) - Text("settings.localModels.cloudPolish.caption") - .font(TypeStyle.caption2) - .foregroundStyle(palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + Toggle(isOn: $config.localModeCloudPolishEnabled) { + Text("settings.localModels.cloudPolish.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) } + .toggleStyle(.switch) + .tint(palette.accent) .padding(.horizontal, Spacing.md) - .padding(.vertical, Spacing.xs) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) } // MARK: Helpers diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index ba7c7b0..96f42cc 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -74,6 +74,9 @@ struct SettingsView: View { apiSection } languageAndModelsSection + if config.engineMode == "local" { + localEngineSettingsSection + } if config.engineMode == "cloud" { systemPromptLinkSection } @@ -131,8 +134,9 @@ struct SettingsView: View { // now sits above the cloud-polish toggle / local models // block so the row that maps to microphone input comes // first, the row that maps to post-processing comes - // second, and translation (post-post-processing) sits at - // the bottom. + // second. Translation moved into the local-engine + // group (see `localEngineSettingsSection`) so the local + // engine reads as one cohesive card on its own. LocalePickerRow( locales: effectiveLocales, selection: Binding( @@ -140,14 +144,6 @@ struct SettingsView: View { set: { config.localeId = $0 } ) ) - if config.engineMode == "local" { - Divider().background(palette.divider) - LocalModelsGroup(config: config) - } - Divider().background(palette.divider) - if config.isTranslationRowVisible { - TranslationPickerRow(config: config, isVisible: true) - } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( @@ -178,6 +174,19 @@ struct SettingsView: View { } } + /// v0.2.1 follow-up: dedicated section for the local engine's + /// settings (cloud-polish toggle + translation row). Renders only + /// when `engineMode == "local"` so the cloud-engine user doesn't + /// see rows that are inert for them. The translation row lives + /// inside `LocalModelsGroup` so it shares the group's surface card + /// chrome — see `LocalEngineSettingsRows.swift` for the layout. + private var localEngineSettingsSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + sectionHeader("settings.localEngine.title") + LocalModelsGroup(config: config) + } + } + private var providerSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { sectionHeader("settings.provider.title") diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 2e005d4..3284ec2 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -119,7 +119,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld ready"; "settings.localModels.cloudPolish.title" = "Cloud polish after ASR"; "settings.localModels.cloudPolish.subtitle" = "Sends the transcript to your configured cloud LLM (DeepSeek by default) for cleanup. Enable only when iOS speech recognition struggles — noisy far-field audio, strong accents, etc. Requires a DeepSeek API key."; -"settings.localModels.cloudPolish.caption" = "Uses DeepSeek to polish transcripts in local mode."; +"settings.localEngine.title" = "Local engine"; "settings.language.subtitle.cloud" = "Recognition language and text processing mode."; "settings.language.subtitle.local" = "Recognition language."; "settings.mode.title" = "Mode"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 91291ed..bfc7a87 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -119,7 +119,7 @@ "settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪"; "settings.localModels.cloudPolish.title" = "识别后云端润色"; "settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。"; -"settings.localModels.cloudPolish.caption" = "本地模式下,使用 DeepSeek 进行云端润色。"; +"settings.localEngine.title" = "本地引擎"; "settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。"; "settings.language.subtitle.local" = "选择识别语言。"; "settings.mode.title" = "模式"; diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 4ed40b4..9cb517f 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -109,8 +109,8 @@ public actor PolishingService { // v0.2.1 follow-up: when the caller pins a provider id (the // local engine pins DeepSeek) we still want to honor the // injected test client, but we have to re-derive the - // preset/baseURL/model triplet from the *override* so the - // injected client gets the right values when it's nil. + // preset/baseURL/model/apiKey quartet from the *override* so + // the injected client gets the right values when it's nil. let effectiveProviderId = providerIdOverride ?? store.providerId let client: LLMClient if let injectedClient { @@ -118,8 +118,25 @@ public actor PolishingService { } else { let preset = LLMProvider.provider(id: effectiveProviderId) let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL - let model = store.model.isEmpty ? preset.defaultModel : preset.defaultModel - client = OpenAICompatibleClient(baseURL: baseURL, apiKey: store.apiKey, model: model) + // Pre-existing typo fix: the user-overridden `store.model` + // path was returning `preset.defaultModel` on both branches, + // silently ignoring the user's custom model field. Restore + // the asymmetry so the user override actually wins. + let model = store.model.isEmpty ? preset.defaultModel : store.model + let apiKey: String + if effectiveProviderId == "deepseek" { + let preconfigured = PreconfiguredKeys.deepseek + if preconfigured == "TODO_FILL_LATER_DEEPSEEK_KEY" { + // Placeholder still in place — refuse the round- + // trip so the UI can surface a "build not + // configured" hint instead of a 401. + throw PolishError.missingAPIKey + } + apiKey = preconfigured + } else { + apiKey = store.apiKey + } + client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model) } let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt) let budget = effectiveTimeout(for: trimmed) diff --git a/OSGKeyboardShared/Services/PreconfiguredKeys.swift b/OSGKeyboardShared/Services/PreconfiguredKeys.swift new file mode 100644 index 0000000..9656033 --- /dev/null +++ b/OSGKeyboardShared/Services/PreconfiguredKeys.swift @@ -0,0 +1,50 @@ +// PreconfiguredKeys.swift +// OSGKeyboard · Shared +// +// v0.2.1 follow-up: preconfigured API keys for built-in cloud providers +// the keyboard ships with out of the box. Today the only one is DeepSeek +// — the local engine's default polish vendor (see +// `ProviderConfig.localModeProviderId`). Future builds may pre-fill +// additional providers as we harden them. +// +// These constants live in source so a developer building from the repo +// can swap in their own key once and have every Debug / TestFlight build +// "just work" without round-tripping the Keychain settings UI. +// +// IMPORTANT: Replace the placeholder string with a real key before +// shipping a build. The DEBUG assert below catches the placeholder at +// launch so nobody accidentally publishes an "always 401" build. + +import Foundation + +public enum PreconfiguredKeys { + /// Placeholder string we ship in the repo. Any value other than + /// this is treated as "configured". + private static let placeholder = "TODO_FILL_LATER_DEEPSEEK_KEY" + + /// Preconfigured DeepSeek API key. Replace `placeholder` with a + /// real key in `Sources/.../PreconfiguredKeys.swift` before + /// distributing a build. + public static let deepseek: String = placeholder + + #if DEBUG + /// Forces a lazy init at app launch in DEBUG builds so the assert + /// below fires immediately when somebody forgets to swap the + /// placeholder. The boolean is intentionally unused at runtime — + /// it's a tripwire. + public static let isDeepseekConfigured: Bool = { + assert( + deepseek != placeholder, + "DeepSeek preconfigured key not filled — replace TODO_FILL_LATER_DEEPSEEK_KEY in PreconfiguredKeys.swift before building" + ) + return deepseek != placeholder + }() + + /// Touch the tripwire so the assert fires at launch rather than + /// only the first time the local engine actually tries to polish. + /// Called from app startup; safe to invoke multiple times. + public static func assertProductionReadinessAtLaunch() { + _ = isDeepseekConfigured + } + #endif +} \ No newline at end of file From 1bdb8824ac745d179552b37e2c7b26ff14a2199b Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:08:00 +0800 Subject: [PATCH 7/8] feat: polish scenarios and stabilize keyboard layout height Add preset-driven polish scenarios (Settings, onboarding, ScenarioChip) with ScenarioPrompt and style directives; drive keyboard height from content (240pt) and use viewIsAppearing encapsulated-height offset for smoother keyboard switches; remove redundant StatusBadge and hide system dictation via hasDictationKey. --- CHANGELOG.md | 21 ++ OSGKeyboard/Services/FlowSessionManager.swift | 30 ++- OSGKeyboard/Views/KeyboardPreviewStub.swift | 28 +-- OSGKeyboard/Views/OnboardingView.swift | 50 ++--- OSGKeyboard/Views/ScenarioPickerRow.swift | 59 ++++++ OSGKeyboard/Views/SettingsView.swift | 56 ++--- .../Views/SystemPromptSettingsView.swift | 7 + OSGKeyboard/en.lproj/Localizable.strings | 5 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 7 +- OSGKeyboardExt/KeyboardViewController.swift | 136 +++++++++--- .../Services/AppGroupPersistor.swift | 29 ++- OSGKeyboardExt/Views/KeyboardRootView.swift | 195 ++++++++---------- OSGKeyboardExt/Views/RecordButton.swift | 17 +- OSGKeyboardExt/Views/ScenarioChip.swift | 59 ++++++ OSGKeyboardExt/Views/TranslationChip.swift | 6 +- OSGKeyboardExt/en.lproj/Keyboard.strings | 8 +- OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 10 +- OSGKeyboardShared/Models/PolishScenario.swift | 55 +++++ OSGKeyboardShared/Models/ProviderConfig.swift | 65 ++++-- .../Services/AppGroupConfigDarwin.swift | 22 ++ .../Services/AppGroupStore.swift | 70 +++++++ .../Services/FlowSessionDarwin.swift | 13 +- .../Services/KeyboardState.swift | 30 ++- .../Services/PolishingService.swift | 101 ++++++--- .../Services/PreconfiguredKeys.swift | 4 +- .../Services/ScenarioPrompt.swift | 59 ++++++ .../Services/ScenarioStyleDirective.swift | 171 +++++++++++++++ .../Services/TranslationPrompt.swift | 40 ++-- OSGKeyboardShared/en.lproj/Shared.strings | 20 +- .../zh-Hans.lproj/Shared.strings | 20 +- OSGKeyboardTests/LLMClientTests.swift | 190 +++++++++++++++++ project.yml | 4 +- 32 files changed, 1247 insertions(+), 340 deletions(-) create mode 100644 OSGKeyboard/Views/ScenarioPickerRow.swift create mode 100644 OSGKeyboardExt/Views/ScenarioChip.swift create mode 100644 OSGKeyboardShared/Models/PolishScenario.swift create mode 100644 OSGKeyboardShared/Services/AppGroupConfigDarwin.swift create mode 100644 OSGKeyboardShared/Services/ScenarioPrompt.swift create mode 100644 OSGKeyboardShared/Services/ScenarioStyleDirective.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index be51fda..ad954f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Polish scenarios**: pick a writing context (Daily Chat, Social Network / 小红书, Instagram / 微博, Goofy, Work, Document, TODO, Custom) in Settings, onboarding, and the keyboard top-bar `ScenarioChip`. Presets drive `ScenarioPrompt`; Custom reuses the system prompt editor. + +### Changed +- **Scenario output formats**: shared `ScenarioStyleDirective` enforces structural rules (Work → mandatory bullets for multi-item input, TODO → checklist). The same directive applies to translate-and-polish via `TranslationPrompt`. + +## [0.3.0] - 2026-06-24 + +### Added +- **Post-polish translation** for cloud and local engines: target-language picker in Settings / onboarding, `TranslationChip` on the keyboard top bar, and `PolishMode.translate` in `PolishingService`. +- **Preconfigured DeepSeek key** (`PreconfiguredKeys`) for local-engine cloud polish without round-tripping the Settings API card. + +### Changed +- **Local-engine translation is gated on cloud polish**: the translation row and LLM step are hidden/disabled until "Cloud polish after ASR" is enabled; turning polish off clears a stale translation target. +- **Local-engine LLM endpoint pinning**: when the pipeline routes through DeepSeek, base URL and model come from the DeepSeek preset instead of the user's cloud-provider settings (fixes DeepSeek key + Qwen URL 401s). + +### Fixed +- **Translation chip always visible** when the engine can run cloud LLM (cloud always; local when cloud polish is on) — no longer hidden when target is "不翻译". +- **Keyboard translation menu** first item shows "不翻译"; chip label when off stays "翻译". +- **Translation toggle race**: 2.5s protect window after chip writes, Darwin config notification, host finalize re-reads App Group; turning off cloud polish no longer clears saved translation target. + ## [0.2.1] - 2026-06-24 ### Removed diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 7604f3c..be493f0 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -560,11 +560,12 @@ final class FlowSessionManager: ObservableObject { let engineMode = store.engineMode let chunkNote = Self.chunkWarningMessage(chunkWarnings) - let shouldPolish = (engineMode == "cloud") - || (engineMode == "local" && store.localModeCloudPolishEnabled) + // Re-read App Group at finalize so chip-side translation changes + // from the keyboard extension are visible before polish/translate. + let pipelineStore = AppGroupStore() - if !shouldPolish { - // Local engine, cloud-polish toggle off — pure ASR. + if !pipelineStore.shouldRunCloudLLMStep { + // Local engine with cloud polish off — ASR-only. FlowSessionBridge.storeTranscriptionResult(text, polishWarning: chunkNote) FlowDiagnostics.log( "finalize ASR-only total=\(String(format: "%.1f", Date().timeIntervalSince(pipelineStarted)))s " + @@ -580,8 +581,18 @@ final class FlowSessionManager: ObservableObject { var delivered = text let polishStarted = Date() + let polishMode = pipelineStore.polishModeForPipeline + FlowDiagnostics.log( + "finalize LLM mode=\(Self.polishModeLogLabel(polishMode)) " + + "translationTarget=\(pipelineStore.translationTargetLocaleId) " + + "cloudPolish=\(pipelineStore.localModeCloudPolishEnabled)" + ) do { - let polished = try await polisher.polish(text) + let polished = try await polisher.polish( + text, + mode: polishMode, + providerIdOverride: pipelineStore.polishProviderIdOverride + ) delivered = polished FlowSessionBridge.storeTranscriptionResult(polished, polishWarning: chunkNote) FlowDiagnostics.log( @@ -611,6 +622,15 @@ final class FlowSessionManager: ObservableObject { debug("utterance finalized length=\(text.count)") } + private static func polishModeLogLabel(_ mode: PolishingService.PolishMode) -> String { + switch mode { + case .polish: + return "polish" + case .translate(let targetLocaleId): + return "translate(\(targetLocaleId))" + } + } + private static func chunkWarningMessage(_ warnings: [String]) -> String? { guard !warnings.isEmpty else { return nil } return warnings.joined(separator: "\n") diff --git a/OSGKeyboard/Views/KeyboardPreviewStub.swift b/OSGKeyboard/Views/KeyboardPreviewStub.swift index c490fce..44a5a17 100644 --- a/OSGKeyboard/Views/KeyboardPreviewStub.swift +++ b/OSGKeyboard/Views/KeyboardPreviewStub.swift @@ -50,7 +50,7 @@ struct KeyboardPreviewStub: View { .padding(.top, 4) .padding(.bottom, 6) } - .frame(height: 280) + .frame(height: 240) } // MARK: - Top bar @@ -60,7 +60,6 @@ struct KeyboardPreviewStub: View { modeChip localeChip Spacer(minLength: 0) - statusBadge Button(action: openSettings) { Image(systemName: "gearshape.fill") .font(.system(size: 13, weight: .medium)) @@ -149,31 +148,6 @@ struct KeyboardPreviewStub: View { } } - private var statusBadge: some View { - Group { - switch phase { - case .idle: - EmptyView() - case .recording: - HStack(spacing: 4) { - Circle().fill(palette.recordRed).frame(width: 6, height: 6) - Text("keyboard.rec").font(TypeStyle.caption2).foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.xs).padding(.vertical, 3) - .background(palette.surface, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - case .processing: - HStack(spacing: 4) { - Circle().fill(palette.accent).frame(width: 6, height: 6) - Text("···").font(TypeStyle.caption2).foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.xs).padding(.vertical, 3) - .background(palette.surface, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - } - } - } - // MARK: - Centre area private var centreArea: some View { diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index ce115f6..c7a056a 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -734,23 +734,6 @@ private struct APISetupPage: View { .padding(.horizontal, Spacing.lg) APISettingsCard(config: config) .padding(.horizontal, Spacing.lg) - // v0.2.1 follow-up: translation row lives on the - // onboarding engine page so first-time users can - // pick a target language before they ever see the - // keyboard. v0.2.1 final review: both engines now - // show the row (the local engine routes the polish - // step through DeepSeek, so the constraint is gone). - // Wrapped in the same surface card chrome as the - // APISettingsCard above for visual symmetry. - // - // v0.2.1 final review (topbar cleanup pass): the - // surface card chrome is owned by `translationSection` - // itself, so both branches get it for free — no - // per-branch duplication. - if config.isTranslationRowVisible { - translationSection - .padding(.horizontal, Spacing.lg) - } } else { // v0.2.0: local engine is iOS `SpeechAnalyzer` only. // Surface the cloud-polish toggle and a one-line @@ -772,38 +755,31 @@ private struct APISetupPage: View { ) } .padding(.horizontal, Spacing.lg) - // v0.2.1 final review: same surface card chrome as - // the cloud branch — the row now renders for both - // engines. - if config.isTranslationRowVisible { - translationSection - .padding(.horizontal, Spacing.lg) - } + } + + if config.isPolishScenarioRowVisible { + postProcessingSection + .padding(.horizontal, Spacing.lg) } } .padding(.bottom, Spacing.xxxl) } } - /// v0.2.1 follow-up: extracted so both engine branches can render - /// the same surface card + picker. `TranslationPickerRow` itself - /// reads `ProviderConfig.translationTargetLocaleId` directly, so - /// picking a locale in onboarding flows through to the keyboard - /// extension on the next `load()` cycle. - /// - /// v0.2.1 final review: the section header now reads - /// `settings.translation.afterPolish` (renamed alongside the row - /// title in `TranslationPickerRow`) so the section and row read - /// as one cohesive group. - private var translationSection: some View { + /// Polish scenario + optional translation target for cloud onboarding. + private var postProcessingSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - Text("settings.translation.afterPolish") + Text("settings.polishScenario.section") .font(TypeStyle.caption2) .foregroundStyle(palette.textSecondary) .textCase(.uppercase) .frame(maxWidth: .infinity, alignment: .leading) VStack(spacing: 0) { - TranslationPickerRow(config: config, isVisible: true) + ScenarioPickerRow(config: config, isVisible: true) + if config.isTranslationRowVisible { + Divider().background(palette.divider) + TranslationPickerRow(config: config, isVisible: true) + } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( diff --git a/OSGKeyboard/Views/ScenarioPickerRow.swift b/OSGKeyboard/Views/ScenarioPickerRow.swift new file mode 100644 index 0000000..1ae3ad3 --- /dev/null +++ b/OSGKeyboard/Views/ScenarioPickerRow.swift @@ -0,0 +1,59 @@ +// ScenarioPickerRow.swift +// OSGKeyboard · Main App +// +// Single-row polish scenario picker. Maps menu choices to +// `ProviderConfig.polishScenarioId`. Custom scenario uses the existing +// system prompt editor (linked from Settings when selected). + +import SwiftUI +import OSGKeyboardShared + +struct ScenarioPickerRow: View { + @Environment(\.themePalette) private var palette: ThemePalette + @ObservedObject var config: ProviderConfig + + var isVisible: Bool = true + + var body: some View { + if isVisible { + HStack { + Text("settings.polishScenario.title") + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Spacer() + Menu { + ForEach(PolishScenarioCatalog.all) { scenario in + Button { + config.polishScenarioId = scenario.id + } label: { + if config.polishScenarioId == scenario.id { + Label(displayLabel(for: scenario), systemImage: "checkmark") + } else { + Text(displayLabel(for: scenario)) + } + } + } + } label: { + HStack(spacing: 6) { + Text(currentLabel) + .font(TypeStyle.body) + .foregroundStyle(palette.textPrimary) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(palette.textTertiary) + } + } + } + .padding(.horizontal, Spacing.md) + .frame(minHeight: SettingsListMetrics.singleLineMinHeight) + } + } + + private var currentLabel: String { + displayLabel(for: PolishScenarioCatalog.resolve(config.polishScenarioId)) + } + + private func displayLabel(for scenario: PolishScenario) -> String { + PolishScenarioCatalog.displayName(for: scenario.id, language: config.uiLanguage) + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 96f42cc..46cd7db 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -62,6 +62,7 @@ struct SettingsView: View { VStack(spacing: Spacing.md) { appLanguageSection engineSection + languageAndPolishSection // v0.2.1: hide provider/api card when the // local engine is active regardless of the // cloud-polish toggle. Local mode is @@ -73,13 +74,9 @@ struct SettingsView: View { providerSection apiSection } - languageAndModelsSection if config.engineMode == "local" { localEngineSettingsSection } - if config.engineMode == "cloud" { - systemPromptLinkSection - } if presentation == .tab { footerLinks } @@ -124,19 +121,12 @@ struct SettingsView: View { EnginePickerSection(config: config) } - // MARK: - Language & on-device models + // MARK: - Language & polish - private var languageAndModelsSection: some View { + private var languageAndPolishSection: some View { VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.language.title") + sectionHeader("settings.languageAndPolish.title") VStack(spacing: 0) { - // v0.2.1: language tab reorder — ASR locale ("识别语言") - // now sits above the cloud-polish toggle / local models - // block so the row that maps to microphone input comes - // first, the row that maps to post-processing comes - // second. Translation moved into the local-engine - // group (see `localEngineSettingsSection`) so the local - // engine reads as one cohesive card on its own. LocalePickerRow( locales: effectiveLocales, selection: Binding( @@ -144,6 +134,23 @@ struct SettingsView: View { set: { config.localeId = $0 } ) ) + if config.isPolishScenarioRowVisible { + Divider().background(palette.divider) + ScenarioPickerRow(config: config, isVisible: true) + if config.engineMode == "cloud", config.isTranslationRowVisible { + Divider().background(palette.divider) + TranslationPickerRow(config: config, isVisible: true) + } + if config.isCustomPolishScenario { + Divider().background(palette.divider) + NavigationLink { + SystemPromptSettingsView(config: config) + } label: { + footerNavigationRow(title: "settings.systemPrompt.edit") + } + .buttonStyle(.plain) + } + } } .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) .overlay( @@ -246,27 +253,6 @@ struct SettingsView: View { dynamicLocales = entries } - // MARK: - System prompt (cloud only) - - private var systemPromptLinkSection: some View { - VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { - sectionHeader("settings.systemPrompt.title") - VStack(spacing: 0) { - NavigationLink { - SystemPromptSettingsView(config: config) - } label: { - footerNavigationRow(title: "settings.systemPrompt.edit") - } - .buttonStyle(.plain) - } - .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: Radius.large, style: .continuous) - .stroke(palette.divider, lineWidth: 0.5) - ) - } - } - // MARK: - Footer links (tab settings only) private var footerLinks: some View { diff --git a/OSGKeyboard/Views/SystemPromptSettingsView.swift b/OSGKeyboard/Views/SystemPromptSettingsView.swift index 8612363..2a7cbf5 100644 --- a/OSGKeyboard/Views/SystemPromptSettingsView.swift +++ b/OSGKeyboard/Views/SystemPromptSettingsView.swift @@ -20,6 +20,13 @@ struct SystemPromptSettingsView: View { .foregroundStyle(palette.textTertiary) .fixedSize(horizontal: false, vertical: true) + if config.isCustomPolishScenario { + Text("settings.polishScenario.customHint") + .font(TypeStyle.caption2) + .foregroundStyle(palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + TextEditor(text: $config.systemPrompt) .font(TypeStyle.mono) .scrollContentBackground(.hidden) diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 3284ec2..730c1b2 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -108,9 +108,14 @@ "provider.custom" = "Custom"; "settings.api.title" = "API"; "settings.language.title" = "Language"; +"settings.languageAndPolish.title" = "Language & Polish"; // v0.2.1: translation feature "settings.translation.afterPolish" = "Polish then translate"; "settings.translation.off" = "Don't translate"; +"settings.polishScenario.section" = "Polish"; +"settings.polishScenario.title" = "Scenario"; +"settings.polishScenario.hint" = "Pick a scenario to match how you write. Choose Custom to edit the full system prompt."; +"settings.polishScenario.customHint" = "Custom scenario: edit the full system prompt below."; "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 bfc7a87..970448d 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -98,7 +98,7 @@ "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.subtitle" = "本地转写 + 你配置的 API 润色,文字发往该第三方服务"; -"settings.provider.title" = "提供商"; +"settings.provider.title" = "云端引擎"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "provider.openai" = "OpenAI"; "provider.deepseek" = "DeepSeek"; @@ -108,9 +108,14 @@ "provider.custom" = "自定义"; "settings.api.title" = "接口"; "settings.language.title" = "语言"; +"settings.languageAndPolish.title" = "语言与润色"; // v0.2.1: 翻译功能 "settings.translation.afterPolish" = "润色后翻译"; "settings.translation.off" = "不翻译"; +"settings.polishScenario.section" = "润色"; +"settings.polishScenario.title" = "润色场景"; +"settings.polishScenario.hint" = "选择适合的使用场景。选「自定义」可编辑完整润色指令。"; +"settings.polishScenario.customHint" = "自定义场景:在下方编辑完整润色指令。"; "settings.languageModels.title" = "语言与模型"; "settings.localModels.title" = "本地模型"; "settings.localModels.speechRole" = "语音识别"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 4688074..2472391 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController { private var wasFlowSessionActive = false private var flowSessionMonitorTask: Task? private var flowSessionDarwinObserver: FlowSessionDarwinObserver? + private var configDarwinObserver: FlowSessionDarwinObserver? + /// Grace period after a chip-side translation write during which the + /// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`. + private var translationConfigProtectedUntil: Date? + private var polishScenarioConfigProtectedUntil: Date? private var isAwaitingFlowResult = false private var lastFlowAutoStartAttempt: TimeInterval = 0 private static let flowAutoStartCooldown: TimeInterval = 20 + /// Drives the keyboard slot height on `view` (priority 999). + private var keyboardHeightConstraint: NSLayoutConstraint? + /// Runtime value read from `UIView-Encapsulated-Layout-Height` (varies by device). + private var systemEncapsulatedHeight: CGFloat = 228 + + private var targetKeyboardHeight: CGFloat { + KeyboardRootView.totalHeight + } // MARK: - Lifecycle public override func viewDidLoad() { super.viewDidLoad() - // Keyboard extension MUST opt in to self-sizing, otherwise - // our SwiftUI `frame(height:)` is ignored and the keyboard is - // cropped by the system chrome (Spotlight bar, home indicator). - inputView?.allowsSelfSizing = true + installKeyboardHeight() + configureDictationBehavior() installStateActions() installSwiftUI() loadPersistedConfig() consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() installFlowSessionDarwinObserver() + installConfigDarwinObserver() refreshFlowSessionState() } @@ -108,6 +120,7 @@ public final class KeyboardViewController: UIInputViewController { public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) + configureDictationBehavior() KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess) consumePendingDictationResultIfNeeded() refreshDictationProgressStateIfNeeded() @@ -115,6 +128,17 @@ public final class KeyboardViewController: UIInputViewController { startFlowSessionMonitor() } + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + applyPresentationHeightOffset() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Presentation finished — lock to the true content-driven height. + keyboardHeightConstraint?.constant = targetKeyboardHeight + } + public override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() cancelPipeline() @@ -126,6 +150,14 @@ public final class KeyboardViewController: UIInputViewController { refreshDictationProgressStateIfNeeded() } + // MARK: - System keyboard chrome + + /// Tell iOS this keyboard provides its own dictation entry (centre mic). + /// When `true`, the system dictation key in the bottom-right is not shown. + private func configureDictationBehavior() { + hasDictationKey = true + } + // MARK: - Wiring private func installStateActions() { @@ -141,16 +173,49 @@ public final class KeyboardViewController: UIInputViewController { // v0.2.1 follow-up: removed `setTranslationEnabled` — the chip // / picker only writes the locale id now; `enabled` is derived. state.setTranslationTargetLocaleId = { [weak self] id in self?.persistTranslationTargetLocaleId(id) } + state.setPolishScenarioId = { [weak self] id in self?.persistPolishScenarioId(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() } } + /// Reserve keyboard height on `view`. During presentation iOS adds a + /// private encapsulated height; `viewIsAppearing` applies the community + /// offset trick (target − encapsulated) so the slot lands at `target`. + private func installKeyboardHeight() { + let constraint = view.heightAnchor.constraint( + equalToConstant: targetKeyboardHeight + ) + constraint.priority = UILayoutPriority(999) + constraint.isActive = true + keyboardHeightConstraint = constraint + } + + /// Read the system encapsulated height and prime our constraint so iOS + /// presentation math (custom + encapsulated) equals `targetKeyboardHeight`. + /// See: https://developer.apple.com/forums/thread/799003 + private func applyPresentationHeightOffset() { + if let encapsulated = view.constraints.first(where: { constraint in + constraint.firstItem as? UIView === view + && constraint.firstAttribute == .height + && constraint !== keyboardHeightConstraint + }) { + systemEncapsulatedHeight = encapsulated.constant + } + let primed = targetKeyboardHeight - systemEncapsulatedHeight + keyboardHeightConstraint?.constant = max(0, primed) + } + private func installSwiftUI() { let root = KeyboardRootView(state: state) let host = UIHostingController(rootView: root) host.view.backgroundColor = .clear host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.clipsToBounds = false + // Keep keyboard layout anchored to the top edge across keyboard + // switches — don't let UIHostingController re-inset for safe area. + host.view.insetsLayoutMarginsFromSafeArea = false + host.safeAreaRegions = [] addChild(host) view.addSubview(host.view) NSLayoutConstraint.activate([ @@ -158,11 +223,6 @@ public final class KeyboardViewController: UIInputViewController { host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), host.view.topAnchor.constraint(equalTo: view.topAnchor), host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - // Pin the host view to a fixed height matching KeyboardRootView.totalHeight. - // Without this, iOS lets the system chrome (Spotlight, home - // indicator) bleed into our content. With it, our content area - // is fully reserved and the keyboard feels intentional. - host.view.heightAnchor.constraint(equalToConstant: KeyboardRootView.totalHeight) ]) host.didMove(toParent: self) self.hosting = host @@ -203,8 +263,28 @@ public final class KeyboardViewController: UIInputViewController { flowSessionMonitorTask = nil } + private func installConfigDarwinObserver() { + configDarwinObserver = FlowSessionDarwinObserver( + notificationName: AppGroupConfigDarwin.notificationName + ) { [weak self] in + self?.refreshConfigFromAppGroup() + } + } + + private func refreshConfigFromAppGroup() { + persistor.refreshRuntimeFlags( + into: state, + protectTranslationUntil: translationConfigProtectedUntil, + protectPolishScenarioUntil: polishScenarioConfigProtectedUntil + ) + } + private func refreshFlowSessionState() { - persistor.refreshRuntimeFlags(into: state) + persistor.refreshRuntimeFlags( + into: state, + protectTranslationUntil: translationConfigProtectedUntil, + protectPolishScenarioUntil: polishScenarioConfigProtectedUntil + ) consumePendingFlowDeliveryIfNeeded() let active = FlowSessionBridge.isSessionActive() @@ -515,8 +595,9 @@ public final class KeyboardViewController: UIInputViewController { debug("received transcript length=\(trimmed.count)") awaitingDictationResult = false stopDictationWatchdog() - // Local engine: host app delivers raw ASR transcript; insert as-is. - if state.isLocalEngine { + + let runtimeStore = AppGroupStore() + guard runtimeStore.shouldRunCloudLLMStep else { textDocumentProxy.insertText(trimmed) state.lastTranscript = "" if let warning = delivery.polishWarning { @@ -527,28 +608,13 @@ public final class KeyboardViewController: UIInputViewController { } return } - // Cloud engine: always polish via the configured LLM. + + // Cloud engine, or local engine with cloud polish / translation. 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` no longer gates on - // `engineMode == "cloud"` — the row visibility predicate - // already keeps the picker honest, and the local engine's - // translate-and-polish path now routes through DeepSeek. - let polishMode: PolishingService.PolishMode = self.state.isTranslationEffective - ? .translate(targetLocaleId: self.state.translationTargetLocaleId) - : .polish - // v0.2.1: local engine routes through DeepSeek for the - // polish / translate step regardless of the user's chosen - // cloud provider — DeepSeek is cheap and strong on - // Chinese, which is the dominant input for the on-device - // ASR transcript. Cloud engine honors the user's own - // provider id by passing `nil`. - let overrideProviderId: String? = self.state.engineMode == "local" - ? "deepseek" - : nil + let polishMode = runtimeStore.polishModeForPipeline + let overrideProviderId = runtimeStore.polishProviderIdOverride do { let polished = try await self.polisher.polish( trimmed, @@ -656,9 +722,17 @@ public final class KeyboardViewController: UIInputViewController { private func persistTranslationTargetLocaleId(_ id: String) { let resolved = TranslationLanguageCatalog.resolve(id).id state.translationTargetLocaleId = resolved + translationConfigProtectedUntil = Date().addingTimeInterval(2.5) persistor.persist(translationTargetLocaleId: resolved) } + private func persistPolishScenarioId(_ id: String) { + let resolved = PolishScenarioCatalog.resolve(id).id + state.polishScenarioId = resolved + polishScenarioConfigProtectedUntil = Date().addingTimeInterval(2.5) + persistor.persist(polishScenarioId: resolved) + } + // MARK: - Open host app private func openHostApp(path: String = "settings") { diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 6d39e82..1d8e08a 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -40,6 +40,8 @@ public struct AppGroupPersistor { // startup; `refreshRuntimeFlags` keeps the chip in sync while // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId + state.polishScenarioId = store.polishScenarioId + state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled // 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,14 +77,28 @@ public struct AppGroupPersistor { /// Lightweight refresh for flags the host app may update while the /// keyboard stays open (model downloads, engine switches). - public func refreshRuntimeFlags(into state: KeyboardViewController.State) { + /// + /// When `protectTranslationUntil` is in the future, the translation + /// target locale is not overwritten — avoids the 1 Hz poll clobbering + /// a chip selection the user just wrote to the App Group. + public func refreshRuntimeFlags( + into state: KeyboardViewController.State, + protectTranslationUntil: Date? = nil, + protectPolishScenarioUntil: Date? = nil + ) { guard AppGroup.isAvailable else { return } let store = AppGroupStore() state.engineMode = store.engineMode state.localASRBackend = store.localASRBackend - // v0.2.1 follow-up: same as `load` — only the locale is - // persisted, `enabled` is derived. - state.translationTargetLocaleId = store.translationTargetLocaleId + state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled + let shouldProtectTranslation = protectTranslationUntil.map { Date() < $0 } ?? false + if !shouldProtectTranslation { + state.translationTargetLocaleId = store.translationTargetLocaleId + } + let shouldProtectScenario = protectPolishScenarioUntil.map { Date() < $0 } ?? false + if !shouldProtectScenario { + state.polishScenarioId = store.polishScenarioId + } // 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. @@ -127,4 +143,9 @@ public struct AppGroupPersistor { guard AppGroup.isAvailable else { return } AppGroupStore().setTranslationTargetLocaleId(translationTargetLocaleId) } + + public func persist(polishScenarioId: String) { + guard AppGroup.isAvailable else { return } + AppGroupStore().setPolishScenarioId(polishScenarioId) + } } \ No newline at end of file diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 5bc287f..4469abc 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -3,16 +3,17 @@ // // Typeless-inspired keyboard surface. The keyboard is laid out in three // vertical bands, but the entire height is reserved for us — we set -// `inputView.allowsSelfSizing = true` in the view controller so SwiftUI's -// frame is honoured, and we add safe-area insets at the top and bottom so -// the system Spotlight / home-indicator chrome never clips our controls. +// `KeyboardViewController` drives height on `view` (priority 999) and mirrors +// `KeyboardLayoutMetrics.totalHeight` in SwiftUI — see presentation offset +// in `applyPresentationHeightOffset()`. // // ┌───────────────────────────────────────────┐ -// │ [polish] [中] ● ⚙ │ ← top: ~38 pt (+20%) +// │ [polish] [中] ⚙ │ ← header band (top) // │ (transcript preview) │ -// │ │ -// │ (⌫) ◯ mic (↩) │ ← action row: circular -// │ (space) │ flanking buttons +// │ ┊ │ +// │ (⌫) ◯ mic (↩) │ ← action cluster: +// │ (space) │ centred below header +// │ ┊ │ // └───────────────────────────────────────────┘ import SwiftUI @@ -24,8 +25,33 @@ private enum KeyboardLayoutMetrics { static let sideSpaceBarWidth: CGFloat = 19 static let micFlankMinSpacing: CGFloat = 36 static let sideActionStackSpacing: CGFloat = 16 + /// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%). + static let topBarToTranscriptSpacing: CGFloat = Spacing.xs /// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%). static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3 + + // MARK: - Content-driven keyboard height (single source of truth) + static let outerPaddingTop: CGFloat = 2 + static let outerPaddingBottom: CGFloat = 6 + static let topBarHeight: CGFloat = 38 + static let transcriptLineHeight: CGFloat = 22 + static let actionClusterHeight: CGFloat = 132 + /// Fixed breathing room above/below the mic row (not flexible Spacers). + static let actionClusterVerticalGap: CGFloat = Spacing.md + + static var headerBandHeight: CGFloat { + topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight + } + + /// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt + static var totalHeight: CGFloat { + outerPaddingTop + + headerBandHeight + + actionClusterVerticalGap + + actionClusterHeight + + actionClusterVerticalGap + + outerPaddingBottom + } } public struct KeyboardRootView: View { @@ -37,11 +63,9 @@ public struct KeyboardRootView: View { self.state = state } - /// Total keyboard height. We set the same value as a height-anchor - /// constraint in the view controller so the host UIInputView picks - /// it up. - static let totalHeight: CGFloat = 280 - private static let topBarHeight: CGFloat = 38 + /// Content-driven keyboard height; mirrored on `UIInputViewController.view` + /// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`). + static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight private var palette: ThemePalette { colorScheme == .dark ? Palette.dark : Palette.light @@ -49,14 +73,19 @@ public struct KeyboardRootView: View { public var body: some View { VStack(spacing: 0) { - topBar - .frame(height: Self.topBarHeight) + headerBand - centreArea - .frame(maxWidth: .infinity, maxHeight: .infinity) + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap) + + micActionRow + .frame(height: KeyboardLayoutMetrics.actionClusterHeight) + + Color.clear + .frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap) } - .padding(.top, 4) - .padding(.bottom, 6) + .padding(.top, KeyboardLayoutMetrics.outerPaddingTop) + .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) // 透明背景:让系统键盘 chrome 透出,不自行铺色(深浅模式一致)。 .background(Color.clear) .frame(height: Self.totalHeight) @@ -64,6 +93,26 @@ public struct KeyboardRootView: View { .environment(\.themePalette, palette) } + /// Top chip row + transcript / hint line. + private var headerBand: some View { + VStack(spacing: KeyboardLayoutMetrics.topBarToTranscriptSpacing) { + topBar + .frame(height: KeyboardLayoutMetrics.topBarHeight) + + TranscriptLine( + phase: state.phase, + transcript: state.lastTranscript, + flowSessionActive: state.flowSessionActive, + isLocalEngine: state.isLocalEngine, + localModelsReady: state.localModelsReady, + localModelsLoaded: state.localModelsLoaded, + openSettings: state.openSettings, + startFlowSession: state.startFlowSession + ) + .frame(height: KeyboardLayoutMetrics.transcriptLineHeight) + } + } + // MARK: - Top bar private var topBar: some View { @@ -73,22 +122,20 @@ public struct KeyboardRootView: View { } else { CloudEngineChip() } + if state.isPolishScenarioChipVisible { + ScenarioChip(state: state) + } 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). - // v0.2.1 final review: only render the chip when translation - // is actually on. Off-by-default keeps the top bar compact - // for users who don't need translation; the menu still lives - // in onboarding so the feature is discoverable. - if state.translationEnabled { + // v0.3: always show the translation chip when the active + // engine can run the cloud LLM step — off-by-default keeps + // the menu reachable so the user can pick a target language + // without opening Settings. + if state.isTranslationChipVisible { TranslationChip(state: state) } Spacer(minLength: 0) - StatusBadge(phase: state.phase, onDeviceSupported: state.onDeviceSupported) Button(action: state.openSettings) { Image(systemName: "gearshape.fill") .font(.system(size: 14, weight: .medium)) @@ -103,35 +150,11 @@ public struct KeyboardRootView: View { .padding(.horizontal, Spacing.md) } - // MARK: - Centre area - - private var centreArea: some View { - VStack(spacing: Spacing.xxs) { - TranscriptLine( - phase: state.phase, - transcript: state.lastTranscript, - flowSessionActive: state.flowSessionActive, - isLocalEngine: state.isLocalEngine, - localModelsReady: state.localModelsReady, - localModelsLoaded: state.localModelsLoaded, - openSettings: state.openSettings, - startFlowSession: state.startFlowSession - ) - .frame(height: 22) - - Spacer(minLength: 0) - - micActionRow - .padding(.bottom, Spacing.xs) - - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - } + // MARK: - Action cluster /// Delete (left), mic (centre), return + space stacked on the right. - /// HStack vertical alignment keeps delete, mic centre, and the gap - /// between return/space on one horizontal axis. + /// Fixed vertical gaps in `body` keep the cluster centred without + /// flexible Spacers consuming extra keyboard height. private var micActionRow: some View { HStack(alignment: .center, spacing: 0) { CircularToolbarButton(systemName: "delete.left", label: "delete") { @@ -186,19 +209,19 @@ extension KeyboardRootView { #if DEBUG #Preview("Keyboard · Idle") { KeyboardRootView(state: KeyboardViewController.State.previewIdle) - .frame(width: 390, height: 280) + .frame(width: 390, height: KeyboardRootView.totalHeight) .preferredColorScheme(.dark) } #Preview("Keyboard · Recording") { KeyboardRootView(state: KeyboardViewController.State.previewRecording) - .frame(width: 390, height: 280) + .frame(width: 390, height: KeyboardRootView.totalHeight) .preferredColorScheme(.dark) } #Preview("Keyboard · Processing") { KeyboardRootView(state: KeyboardViewController.State.previewProcessing) - .frame(width: 390, height: 280) + .frame(width: 390, height: KeyboardRootView.totalHeight) .preferredColorScheme(.dark) } #endif @@ -367,62 +390,6 @@ private struct CircularToolbarButton: View { } } -// MARK: - Status badge - -private struct StatusBadge: View { - @Environment(\.themePalette) private var palette: ThemePalette - - let phase: KeyboardViewController.State.Phase - /// Reflects whether the active ASR session is on-device. We surface - /// a small ⚠️ during recording so the user knows their audio is - /// going to the cloud for this locale (and so devs catch it during - /// QA without staring at the Xcode console). - let onDeviceSupported: Bool - - var body: some View { - Group { - switch phase { - case .idle: - EmptyView() - case .requestingPermissions: - EmptyView() - case .recording: - if onDeviceSupported { - dot(color: palette.recordRed, labelKey: "keyboard.status.rec") - } else { - dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true) - } - case .processing: - dot(color: palette.accent, labelKey: "keyboard.status.processing") - case .error: - dot(color: palette.warning, labelKey: "keyboard.status.error") - case .denied: - dot(color: palette.warning, labelKey: "keyboard.status.error") - } - } - } - - private func dot(color: Color, labelKey: String, showWarning: Bool = false) -> some View { - HStack(spacing: 4) { - Circle() - .fill(color) - .frame(width: 6, height: 6) - if showWarning { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.warning) - } - Text(ExtL10n.string(labelKey)) - .font(TypeStyle.caption2) - .foregroundStyle(palette.textSecondary) - } - .padding(.horizontal, Spacing.xs) - .padding(.vertical, 4) - .background(palette.surface, in: Capsule()) - .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) - } -} - // MARK: - Cloud engine chip (cloud always ASR + LLM polish) private struct CloudEngineChip: View { diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index d46cd7b..d589698 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -42,11 +42,20 @@ struct RecordButton: View { return remainingSeconds <= 10 } + /// Decorative rings are sized to stay inside the 132 pt frame applied + /// by `KeyboardRootView` so glow / breath animations are not clipped. + private enum Layout { + static let disc: CGFloat = 104 + static let outerRing: CGFloat = 112 + static let breathRing: CGFloat = 108 + static let glow: CGFloat = 128 + } + var body: some View { ZStack { Circle() .stroke(palette.recordRed.opacity(isUrgent ? 0.55 : 0.35), lineWidth: isUrgent ? 3 : 2) - .frame(width: 150, height: 150) + .frame(width: Layout.breathRing, height: Layout.breathRing) .scaleEffect(breath ? 1.18 : 0.95) .opacity(phase == .recording ? 1 : 0) .animation(Motion.breath, value: breath) @@ -60,7 +69,7 @@ struct RecordButton: View { endRadius: 100 ) ) - .frame(width: 200, height: 200) + .frame(width: Layout.glow, height: Layout.glow) .opacity(phase == .recording ? 0.4 + level * 0.6 : 0) .blur(radius: 18) .animation(Motion.soft, value: phase) @@ -71,7 +80,7 @@ struct RecordButton: View { Color.white.opacity(phase == .idle ? 0.08 : 0.12), lineWidth: 0.5 ) - .frame(width: 140, height: 140) + .frame(width: Layout.outerRing, height: Layout.outerRing) ZStack { Circle() @@ -115,7 +124,7 @@ struct RecordButton: View { } } } - .frame(width: 120, height: 120) + .frame(width: Layout.disc, height: Layout.disc) .animation(Motion.soft, value: phase) .animation(Motion.soft, value: remainingSeconds) } diff --git a/OSGKeyboardExt/Views/ScenarioChip.swift b/OSGKeyboardExt/Views/ScenarioChip.swift new file mode 100644 index 0000000..08864b0 --- /dev/null +++ b/OSGKeyboardExt/Views/ScenarioChip.swift @@ -0,0 +1,59 @@ +// ScenarioChip.swift +// OSGKeyboard · Keyboard Extension +// +// Compact chip on the keyboard top bar for quick polish scenario +// switching. Same Menu pattern as `TranslationChip`. + +import SwiftUI +import OSGKeyboardShared + +struct ScenarioChip: View { + @Environment(\.themePalette) private var palette: ThemePalette + + @ObservedObject var state: KeyboardViewController.State + + var body: some View { + Menu { + ForEach(PolishScenarioCatalog.all) { scenario in + Button { + state.setPolishScenarioId(scenario.id) + } label: { + if scenario.id == state.polishScenarioId { + Label(displayLabel(for: scenario), systemImage: "checkmark") + } else { + Text(displayLabel(for: scenario)) + } + } + } + } label: { + label + } + .menuStyle(.button) + .accessibilityLabel(ExtL10n.text("keyboard.scenario.a11y")) + .accessibilityHint(ExtL10n.text("keyboard.scenario.a11yHint")) + } + + private var label: some View { + HStack(spacing: 4) { + Image(systemName: "text.bubble") + Text(chipText) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .font(TypeStyle.caption2) + .foregroundStyle(palette.textPrimary) + .padding(.horizontal, Spacing.xs + 2) + .padding(.vertical, 6) + .frame(minHeight: 28) + .background(palette.surfaceElevated, in: Capsule()) + .overlay(Capsule().stroke(palette.divider, lineWidth: 0.5)) + } + + private var chipText: String { + PolishScenarioCatalog.chipLabel(for: state.polishScenarioId) + } + + private func displayLabel(for scenario: PolishScenario) -> String { + PolishScenarioCatalog.displayName(for: scenario.id) + } +} diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index 7d45cad..5dcc082 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -19,7 +19,7 @@ // off / on, with the same accent treatment either way. // // Visual states: -// • off → dim outline, "翻译" label +// • off → dim outline, "翻译" chip label (menu first row = "不翻译") // • on (any engine) → accent fill, "→ EN" / "→ 日本語" style label // // Stays in the same visual family as `CloudEngineChip` / `LocaleChip` @@ -88,14 +88,14 @@ struct TranslationChip: View { private func displayLabel(for language: TranslationLanguage) -> String { if language.id == TranslationLanguageCatalog.offLocaleId { - return ExtL10n.string("keyboard.translation.off") + return ExtL10n.string("keyboard.translation.offMenu") } return language.nativeName } private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String { if !enabled { - return ExtL10n.string("keyboard.translation.off") + return ExtL10n.string("keyboard.translation.chip") } // Short form: "→EN" / "→日" style. Falls back to the prompt // language name for languages without a chip-style abbreviation diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index a288ad1..58b9e65 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -176,12 +176,16 @@ "locale.chip.ja-JP" = "日"; "locale.chip.ko-KR" = "韩"; -/* Translation chip (v0.2.1) */ -"keyboard.translation.off" = "Translate"; +/* Translation chip (v0.3) */ +"keyboard.translation.chip" = "Translate"; +"keyboard.translation.offMenu" = "Don't translate"; +"keyboard.translation.off" = "Don't translate"; "keyboard.translation.enable" = "Enable translation"; "keyboard.translation.disable" = "Disable translation"; "keyboard.translation.a11y" = "Translation"; "keyboard.translation.a11yHint" = "Toggle translation or change the target language."; +"keyboard.scenario.a11y" = "Polish scenario"; +"keyboard.scenario.a11yHint" = "Choose how dictation is polished."; /* Mode chip labels (used in both ext + preview stub) */ "mode.off" = "Off"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index c74a1e1..e8d174d 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -76,7 +76,7 @@ "settings.engine.local.legacy" = "端侧 ASR,仅转录,无润色。"; "settings.engine.cloud.title" = "云端识别与润色"; "settings.engine.cloud.subtitle" = "ASR 转录 + LLM 润色,需要 API Key。"; -"settings.provider.title" = "提供商"; +"settings.provider.title" = "云端引擎"; "settings.provider.subtitle" = "选择 LLM 提供商。"; "settings.api.title" = "接口"; "settings.language.title" = "语言"; @@ -176,12 +176,16 @@ "locale.chip.ja-JP" = "日"; "locale.chip.ko-KR" = "韩"; -/* 翻译 chip (v0.2.1) */ -"keyboard.translation.off" = "翻译"; +/* Translation chip (v0.3) */ +"keyboard.translation.chip" = "翻译"; +"keyboard.translation.offMenu" = "不翻译"; +"keyboard.translation.off" = "不翻译"; "keyboard.translation.enable" = "开启翻译"; "keyboard.translation.disable" = "关闭翻译"; "keyboard.translation.a11y" = "翻译"; "keyboard.translation.a11yHint" = "切换翻译开关或修改目标语言。"; +"keyboard.scenario.a11y" = "润色场景"; +"keyboard.scenario.a11yHint" = "选择润色风格或使用场景。"; /* Mode chip labels */ "mode.off" = "关闭"; diff --git a/OSGKeyboardShared/Models/PolishScenario.swift b/OSGKeyboardShared/Models/PolishScenario.swift new file mode 100644 index 0000000..44042bf --- /dev/null +++ b/OSGKeyboardShared/Models/PolishScenario.swift @@ -0,0 +1,55 @@ +// PolishScenario.swift +// OSGKeyboard · Shared +// +// Curated polish scenarios the user picks instead of editing a raw +// system prompt. Display names live in Shared.strings; prompt bodies +// are built by `ScenarioPrompt.make`. + +import Foundation + +public struct PolishScenario: Identifiable, Hashable, Sendable { + public let id: String + /// Shared.strings key for the picker label. + public let labelKey: String + /// Shared.strings key for the compact keyboard chip label. + public let chipLabelKey: String + + public init(id: String, labelKey: String, chipLabelKey: String) { + self.id = id + self.labelKey = labelKey + self.chipLabelKey = chipLabelKey + } +} + +public enum PolishScenarioCatalog { + public static let defaultId = "daily_chat" + public static let customId = "custom" + + /// Order matters — picker / chip render top-to-bottom. + public static let all: [PolishScenario] = [ + PolishScenario(id: "daily_chat", labelKey: "polishScenario.daily_chat", chipLabelKey: "polishScenario.chip.daily_chat"), + PolishScenario(id: "social_lifestyle", labelKey: "polishScenario.social_lifestyle", chipLabelKey: "polishScenario.chip.social_lifestyle"), + PolishScenario(id: "social_short", labelKey: "polishScenario.social_short", chipLabelKey: "polishScenario.chip.social_short"), + PolishScenario(id: "goofy", labelKey: "polishScenario.goofy", chipLabelKey: "polishScenario.chip.goofy"), + PolishScenario(id: "work", labelKey: "polishScenario.work", chipLabelKey: "polishScenario.chip.work"), + PolishScenario(id: "document", labelKey: "polishScenario.document", chipLabelKey: "polishScenario.chip.document"), + PolishScenario(id: "todo", labelKey: "polishScenario.todo", chipLabelKey: "polishScenario.chip.todo"), + PolishScenario(id: customId, labelKey: "polishScenario.custom", chipLabelKey: "polishScenario.chip.custom"), + ] + + public static func isCustom(_ id: String) -> Bool { + id == customId + } + + public static func resolve(_ id: String) -> PolishScenario { + all.first { $0.id == id } ?? all.first { $0.id == defaultId } ?? all[0] + } + + public static func displayName(for id: String, language: AppUILanguage? = nil) -> String { + SharedL10n.string(resolve(id).labelKey, language: language) + } + + public static func chipLabel(for id: String, language: AppUILanguage? = nil) -> String { + SharedL10n.string(resolve(id).chipLabelKey, language: language) + } +} diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 6541110..fb7f2e5 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -51,6 +51,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // who upgraded from a build that wrote it don't see a flash of // "on" state during init, but new writes never touch the key. static let translationTargetLocaleId = "config.translationTargetLocaleId" + static let polishScenarioId = "config.polishScenarioId" } @Published public var providerId: String { @@ -121,7 +122,10 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { /// box. Users opt in from Settings when the iOS ASR output isn't /// strong enough (noisy far-field audio, dialectal Chinese, etc.). @Published public var localModeCloudPolishEnabled: Bool { - didSet { defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled) } + didSet { + defaults.set(localModeCloudPolishEnabled, forKey: Key.localModeCloudPolishEnabled) + AppGroupConfigDarwin.postConfigChanged() + } } /// Host-app UI language. Also mirrored to the App Group for the keyboard extension. @Published public var uiLanguage: AppUILanguage { @@ -146,25 +150,46 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { /// extension can honour it (and so the chip on the keyboard reflects /// the user's choice without a host-app round-trip). @Published public var translationTargetLocaleId: String { - didSet { defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) } + didSet { + defaults.set(translationTargetLocaleId, forKey: Key.translationTargetLocaleId) + AppGroupConfigDarwin.postConfigChanged() + } + } + /// Selected polish scenario preset (e.g. `daily_chat`, `work`) or + /// `custom` when the user edits the raw system prompt. + @Published public var polishScenarioId: String { + didSet { + defaults.set(polishScenarioId, forKey: Key.polishScenarioId) + AppGroupConfigDarwin.postConfigChanged() + } } - /// v0.2.1 follow-up: with the local engine's translate-and-polish - /// path now real (see `localModeProviderId`), `translationEnabled` - /// alone is enough to decide whether the pipeline should translate. - /// Row visibility (`isTranslationRowVisible`) already gates the UI - /// on engines that can actually run the step, so we don't need to - /// re-check `engineMode` here. + /// Whether the pipeline should run translate-and-polish (not just + /// polish). Cloud engine: any selected target locale. Local engine: + /// only when cloud polish is also enabled. public var isTranslationEffective: Bool { - translationEnabled + guard translationEnabled else { return false } + if isLocalEngine { return localModeCloudPolishEnabled } + return true } - /// v0.2.1 follow-up: row visibility predicate. Both engines can - /// now run the cloud translate-and-polish step (the local engine - /// routes through DeepSeek via `localModeProviderId`), so the row - /// is shown whenever an engine mode is selected. + /// Translation picker visibility. Cloud engine: always. Local engine: + /// only when "Cloud polish after ASR" is on — translation is a + /// sub-step of that cloud LLM pass, not a standalone feature. public var isTranslationRowVisible: Bool { - engineMode == "local" || engineMode == "cloud" + if engineMode == "cloud" { return true } + return isLocalEngine && localModeCloudPolishEnabled + } + + /// Polish scenario picker visibility — same gate as translation: + /// only when the pipeline can run a cloud LLM step. + public var isPolishScenarioRowVisible: Bool { + if engineMode == "cloud" { return true } + return isLocalEngine && localModeCloudPolishEnabled + } + + public var isCustomPolishScenario: Bool { + PolishScenarioCatalog.isCustom(polishScenarioId) } public var isConfigured: Bool { @@ -263,6 +288,17 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // conservative default that matches the picker / chip UX). self.translationTargetLocaleId = resolvedDefaults.string(forKey: Key.translationTargetLocaleId) ?? TranslationLanguageCatalog.offLocaleId + if let storedScenario = resolvedDefaults.string(forKey: Key.polishScenarioId) { + self.polishScenarioId = PolishScenarioCatalog.resolve(storedScenario).id + } else { + let savedPrompt = resolvedDefaults.string(forKey: Key.systemPrompt) + ?? AppGroupStore.defaultSystemPrompt(for: pid) + if savedPrompt != AppGroupStore.defaultSystemPrompt(for: pid) { + self.polishScenarioId = PolishScenarioCatalog.customId + } else { + self.polishScenarioId = PolishScenarioCatalog.defaultId + } + } // Cloud no longer exposes off/transcribe; migrate legacy values. if self.engineMode == "cloud", self.modeId != "polish" { @@ -313,6 +349,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { apiKey = "" model = preset.defaultModel systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") + polishScenarioId = PolishScenarioCatalog.defaultId hasAcknowledgedCloudSharing = false } } diff --git a/OSGKeyboardShared/Services/AppGroupConfigDarwin.swift b/OSGKeyboardShared/Services/AppGroupConfigDarwin.swift new file mode 100644 index 0000000..d5308b0 --- /dev/null +++ b/OSGKeyboardShared/Services/AppGroupConfigDarwin.swift @@ -0,0 +1,22 @@ +// AppGroupConfigDarwin.swift +// OSGKeyboard · Shared +// +// Cross-process Darwin notification when App Group config changes +// (translation target, cloud-polish toggle, etc.). Lets the host app +// and keyboard extension pick up writes without waiting on the 1 Hz poll. + +import Foundation + +public enum AppGroupConfigDarwin { + public static let notificationName = "com.osgkeyboard.config.changed" + + public static func postConfigChanged() { + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(notificationName as CFString), + nil, + nil, + true + ) + } +} diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 635a463..50fd422 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -45,6 +45,7 @@ public struct AppGroupStore: @unchecked Sendable { // the `translationEnabled` Bool accessor below is kept as a // computed shim for source compatibility. static let translationTargetLocaleId = "config.translationTargetLocaleId" + static let polishScenarioId = "config.polishScenarioId" } // MARK: - Reads @@ -127,6 +128,11 @@ public struct AppGroupStore: @unchecked Sendable { ?? TranslationLanguageCatalog.offLocaleId } + public var polishScenarioId: String { + let stored = defaults.string(forKey: Key.polishScenarioId) + return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id + } + // MARK: - Writes public func setModeId(_ id: String) { @@ -169,6 +175,70 @@ public struct AppGroupStore: @unchecked Sendable { /// round-trip. public func setTranslationTargetLocaleId(_ id: String) { defaults.set(id, forKey: Key.translationTargetLocaleId) + AppGroupConfigDarwin.postConfigChanged() + } + + public func setPolishScenarioId(_ id: String) { + let resolved = PolishScenarioCatalog.resolve(id).id + defaults.set(resolved, forKey: Key.polishScenarioId) + AppGroupConfigDarwin.postConfigChanged() + } + + /// Whether ASR output should be sent through the cloud LLM step. + /// Cloud engine: always. Local engine: only when cloud polish is + /// enabled (translation is a sub-option of that step). + public var shouldRunCloudLLMStep: Bool { + if engineMode == "cloud" { return true } + return localModeCloudPolishEnabled + } + + /// Whether translate-and-polish should run (vs polish-only). + public var isTranslationEffective: Bool { + guard translationEnabled else { return false } + if engineMode == "local" { return localModeCloudPolishEnabled } + return true + } + + /// Whether the keyboard top-bar translation chip should render. + /// Cloud engine: always. Local engine: when cloud polish is enabled + /// (translation is a sub-option of that LLM step). Independent of + /// whether a target locale is currently selected — the chip stays + /// visible so the user can pick "不翻译" or a language in-place. + public var isTranslationChipVisible: Bool { + if engineMode == "cloud" { return true } + return localModeCloudPolishEnabled + } + + /// Whether the keyboard top-bar scenario chip should render. + public var isPolishScenarioChipVisible: Bool { + if engineMode == "cloud" { return true } + return localModeCloudPolishEnabled + } + + /// System prompt for the polish pipeline honoring scenario selection. + public func resolvedPolishSystemPrompt(providerId: String? = nil) -> String { + if PolishScenarioCatalog.isCustom(polishScenarioId) { + return systemPrompt + } + let pid = providerId ?? self.providerId + return ScenarioPrompt.make( + scenarioId: polishScenarioId, + providerId: pid, + uiLanguage: uiLanguage + ) + } + + /// Polish vs translate-and-polish for the active pipeline. + public var polishModeForPipeline: PolishingService.PolishMode { + isTranslationEffective + ? .translate(targetLocaleId: translationTargetLocaleId) + : .polish + } + + /// Local engine pins the LLM step to DeepSeek; cloud uses the + /// user's configured provider. + public var polishProviderIdOverride: String? { + engineMode == "local" ? "deepseek" : nil } // MARK: - Client diff --git a/OSGKeyboardShared/Services/FlowSessionDarwin.swift b/OSGKeyboardShared/Services/FlowSessionDarwin.swift index 8474ec5..a3ddb85 100644 --- a/OSGKeyboardShared/Services/FlowSessionDarwin.swift +++ b/OSGKeyboardShared/Services/FlowSessionDarwin.swift @@ -20,7 +20,7 @@ public enum FlowSessionDarwin { } } -/// Observes Flow session Darwin notifications on a background thread; invokes +/// Observes Darwin notifications on a background thread; invokes /// `handler` on the main actor. public final class FlowSessionDarwinObserver { private final class Box: @unchecked Sendable { @@ -30,11 +30,16 @@ public final class FlowSessionDarwinObserver { private let box: Box private let token: UnsafeMutableRawPointer + private let notificationName: CFString - public init(handler: @escaping @MainActor () -> Void) { + public init( + notificationName: String = FlowSessionDarwin.notificationName, + handler: @escaping @MainActor () -> Void + ) { let box = Box(handler: handler) self.box = box self.token = Unmanaged.passRetained(box).toOpaque() + self.notificationName = notificationName as CFString CFNotificationCenterAddObserver( CFNotificationCenterGetDarwinNotifyCenter(), @@ -44,7 +49,7 @@ public final class FlowSessionDarwinObserver { let box = Unmanaged.fromOpaque(observer).takeUnretainedValue() Task { @MainActor in box.handler() } }, - FlowSessionDarwin.notificationName as CFString, + self.notificationName, nil, .deliverImmediately ) @@ -54,7 +59,7 @@ public final class FlowSessionDarwinObserver { CFNotificationCenterRemoveObserver( CFNotificationCenterGetDarwinNotifyCenter(), token, - CFNotificationName(FlowSessionDarwin.notificationName as CFString), + CFNotificationName(notificationName), nil ) Unmanaged.fromOpaque(token).release() diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 6669952..ad79a53 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -98,14 +98,29 @@ public final class KeyboardState: ObservableObject { /// Defaults to `offLocaleId` so the keyboard boots in the "off" /// state on first install. @Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId - /// v0.2.1: effective predicate — mirrors `ProviderConfig`. - /// v0.2.1 follow-up: no longer gates on `engineMode == "cloud"` - /// because the local engine's translate-and-polish step now runs - /// (routed through DeepSeek). Row visibility (`isTranslationRowVisible` - /// on `ProviderConfig`) keeps the picker honest, so the keyboard - /// can rely on `translationEnabled` alone here. + /// Selected polish scenario mirrored from App Group. + @Published public var polishScenarioId: String = PolishScenarioCatalog.defaultId + /// v0.2.0: mirrored from App Group — local engine runs the cloud + /// LLM step only when this is `true`. + @Published public var localModeCloudPolishEnabled: Bool = false + /// Whether translate-and-polish is actually armed for the current + /// engine (local requires cloud polish + a target locale). public var isTranslationEffective: Bool { - translationEnabled + guard translationEnabled else { return false } + if isLocalEngine { return localModeCloudPolishEnabled } + return true + } + + /// Whether the keyboard top-bar translation chip should render. + public var isTranslationChipVisible: Bool { + if isLocalEngine { return localModeCloudPolishEnabled } + return true + } + + /// Whether the keyboard top-bar polish scenario chip should render. + public var isPolishScenarioChipVisible: Bool { + if isLocalEngine { return localModeCloudPolishEnabled } + return true } /// Convenience shorthand used by the pipeline and views. @@ -125,6 +140,7 @@ public final class KeyboardState: ObservableObject { /// is derived from the locale id, so there's no separate toggle to /// persist. Wired in `KeyboardViewController.installStateActions`. public var setTranslationTargetLocaleId: (String) -> Void = { _ in } + public var setPolishScenarioId: (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 9cb517f..341c19a 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -8,14 +8,12 @@ // Engine matrix: // - `engineMode == "cloud"` → always polish (cloud engine's whole point). // - `engineMode == "local"`, -// `localModeCloudPolishEnabled == false` → ASR-only, return raw. +// cloud polish disabled → ASR-only, return raw. // - `engineMode == "local"`, -// `localModeCloudPolishEnabled == true` → polish via the user's LLM -// (DeepSeek by default). The local engine gains stronger accuracy on -// noisy / dialectal Chinese at the cost of one cloud round-trip. -// If the user hasn't entered an API key the call falls back to the -// raw transcript and surfaces a warning so the keyboard can show -// the "fill in your key" hint. +// cloud polish enabled → DeepSeek LLM step (polish or translate). +// Translation uses `.translate` + `TranslationPrompt`; polish uses +// the default system prompt. Missing preconfigured DeepSeek key +// throws `missingAPIKey` and callers deliver raw + warning. import Foundation @@ -24,10 +22,8 @@ public actor PolishingService { public enum PolishError: Error, Equatable { case noTranscript case timeout - /// v0.2.0: local engine + cloud-polish-on, but the user hasn't - /// saved an API key in the Keychain. Caller surfaces an Alert - /// telling them to fill it in; we deliver the raw transcript - /// so no data is lost. + /// Local engine DeepSeek step: `PreconfiguredKeys.deepseek` is + /// still the repo placeholder, or cloud engine Keychain is empty. case missingAPIKey } @@ -74,16 +70,10 @@ public actor PolishingService { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw PolishError.noTranscript } - // 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 - // fall back to the raw transcript and throw `missingAPIKey` - // so the UI can surface the "fill in your key" hint. + // Local engine: ASR-only unless cloud polish is enabled + // (translation is a sub-option of that LLM step). if store.engineMode == "local" { - guard store.localModeCloudPolishEnabled else { return trimmed } - guard !store.apiKey.isEmpty else { - throw PolishError.missingAPIKey - } + guard store.shouldRunCloudLLMStep else { return trimmed } return try await polishRemote( trimmed, mode: mode, @@ -117,12 +107,11 @@ public actor PolishingService { client = injectedClient } else { let preset = LLMProvider.provider(id: effectiveProviderId) - let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL - // Pre-existing typo fix: the user-overridden `store.model` - // path was returning `preset.defaultModel` on both branches, - // silently ignoring the user's custom model field. Restore - // the asymmetry so the user override actually wins. - let model = store.model.isEmpty ? preset.defaultModel : store.model + let (baseURL, model) = Self.resolveLLMEndpoint( + store: store, + preset: preset, + providerIdOverride: providerIdOverride + ) let apiKey: String if effectiveProviderId == "deepseek" { let preconfigured = PreconfiguredKeys.deepseek @@ -138,7 +127,11 @@ public actor PolishingService { } client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model) } - let prompt = resolvedSystemPrompt(for: mode, override: systemPrompt) + let prompt = resolvedSystemPrompt( + for: mode, + override: systemPrompt, + providerId: effectiveProviderId + ) let budget = effectiveTimeout(for: trimmed) return try await withThrowingTaskGroup(of: String.self) { group in @@ -161,17 +154,26 @@ public actor PolishingService { /// existing `store.systemPrompt` behaviour so every other call site /// is byte-identical to before. An explicit `override` wins over /// both paths so callers (and tests) can pin a specific prompt. - private func resolvedSystemPrompt(for mode: PolishMode, override: String? = nil) -> String { + private func resolvedSystemPrompt( + for mode: PolishMode, + override: String? = nil, + providerId: String? = nil + ) -> String { if let override, !override.isEmpty { return override } switch mode { case .polish: - return store.systemPrompt + return store.resolvedPolishSystemPrompt(providerId: providerId) case .translate(let targetLocaleId): let target = TranslationLanguageCatalog.resolve(targetLocaleId) - let pid = store.providerId - return TranslationPrompt.make(target: target, providerId: pid) + let pid = providerId ?? store.providerId + return TranslationPrompt.make( + target: target, + providerId: pid, + scenarioId: store.polishScenarioId, + uiLanguage: store.uiLanguage + ) } } @@ -180,4 +182,41 @@ public actor PolishingService { let scaled = timeout + (Double(text.count) / 200.0) * 2.0 return min(max(scaled, timeout), 120) } + + /// Picks base URL + model for one remote polish call. + /// + /// When `providerIdOverride` is set (local engine pins DeepSeek), + /// always use that preset's defaults so cloud-engine settings + /// (e.g. Qwen base URL saved while testing cloud mode) are not + /// mixed with the pinned provider's API key. Cloud engine passes + /// `nil` and keeps honoring `store.baseURL` / `store.model`. + internal static func resolveLLMEndpoint( + store: AppGroupStore, + preset: LLMProvider, + providerIdOverride: String? + ) -> (baseURL: String, model: String) { + if providerIdOverride != nil { + return (preset.defaultBaseURL, preset.defaultModel) + } + let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL + // Pre-existing typo fix: the user-overridden `store.model` + // path was returning `preset.defaultModel` on both branches, + // silently ignoring the user's custom model field. Restore + // the asymmetry so the user override actually wins. + let model = store.model.isEmpty ? preset.defaultModel : store.model + return (baseURL, model) + } +} + +extension PolishingService.PolishError: LocalizedError { + public var errorDescription: String? { + switch self { + case .noTranscript: + return "No transcript to polish." + case .timeout: + return "LLM polish timed out." + case .missingAPIKey: + return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)." + } + } } diff --git a/OSGKeyboardShared/Services/PreconfiguredKeys.swift b/OSGKeyboardShared/Services/PreconfiguredKeys.swift index 9656033..f6a6e53 100644 --- a/OSGKeyboardShared/Services/PreconfiguredKeys.swift +++ b/OSGKeyboardShared/Services/PreconfiguredKeys.swift @@ -25,7 +25,7 @@ public enum PreconfiguredKeys { /// Preconfigured DeepSeek API key. Replace `placeholder` with a /// real key in `Sources/.../PreconfiguredKeys.swift` before /// distributing a build. - public static let deepseek: String = placeholder + public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY" #if DEBUG /// Forces a lazy init at app launch in DEBUG builds so the assert @@ -47,4 +47,4 @@ public enum PreconfiguredKeys { _ = isDeepseekConfigured } #endif -} \ No newline at end of file +} diff --git a/OSGKeyboardShared/Services/ScenarioPrompt.swift b/OSGKeyboardShared/Services/ScenarioPrompt.swift new file mode 100644 index 0000000..7ea9b80 --- /dev/null +++ b/OSGKeyboardShared/Services/ScenarioPrompt.swift @@ -0,0 +1,59 @@ +// ScenarioPrompt.swift +// OSGKeyboard · Shared +// +// Builds the system prompt for a selected polish scenario. Output +// format rules come from `ScenarioStyleDirective` (shared with +// `TranslationPrompt`). + +import Foundation + +public enum ScenarioPrompt { + + public static func make( + scenarioId: String, + providerId: String, + uiLanguage: AppUILanguage? = nil + ) -> String { + let isChineseNative = Self.isChineseNativeProvider(providerId) + let directive = ScenarioStyleDirective.make( + scenarioId: scenarioId, + providerId: providerId, + uiLanguage: uiLanguage + ) + return isChineseNative + ? """ + \(chineseBase) + \(directive) + """ + : """ + \(englishBase) + \(directive) + """ + } + + private static func isChineseNativeProvider(_ providerId: String) -> Bool { + ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) + } + + private static let chineseBase = """ + 你是一位语音输入润色助手。用户用 ASR 转写了一段可能含噪声的口述。 + 硬性要求: + 1) 保留原意,不编造事实;保持输入语言。 + 2) 修复 ASR 噪声(同音错字、漏字、断句错乱)。 + 3) 去掉无意义的口头禅(嗯、啊、那个)。 + 4) 简洁;若下方场景未要求列表/分段,不超出原长 1.5 倍。 + 5) 若场景格式要求列表或分段,允许按格式组织;总长度不超过原长 2 倍。 + 6) 只输出润色后的正文,不要解释、不要加引号。 + """ + + private static let englishBase = """ + You are a voice-input polishing assistant. The user spoke informally and the transcript may contain ASR noise. + Hard rules: + 1) Preserve meaning; do not invent facts; keep the input language. + 2) Fix ASR noise (homophone errors, missing characters, broken segmentation). + 3) Drop filler words (um, uh, like). + 4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length. + 5) If the scenario requires bullets or paragraph breaks, use that layout; total length may be up to 2x when listing. + 6) Output ONLY the polished text. No quotes, no explanation, no preamble. + """ +} diff --git a/OSGKeyboardShared/Services/ScenarioStyleDirective.swift b/OSGKeyboardShared/Services/ScenarioStyleDirective.swift new file mode 100644 index 0000000..a53fc12 --- /dev/null +++ b/OSGKeyboardShared/Services/ScenarioStyleDirective.swift @@ -0,0 +1,171 @@ +// ScenarioStyleDirective.swift +// OSGKeyboard · Shared +// +// Shared output-format rules for each polish scenario. Injected into +// both `ScenarioPrompt` (polish-only) and `TranslationPrompt` +// (translate-and-polish) so the two pipelines stay aligned. +// +// Directives emphasize STRUCTURE (bullets, paragraphs, checklists) +// over tone adjectives — structure is what users notice on short ASR +// transcripts. + +import Foundation + +public enum ScenarioStyleDirective { + + /// Format rules for the given scenario, written in the provider's + /// primary instruction language (Chinese-native vs English-native). + public static func make( + scenarioId: String, + providerId: String, + uiLanguage: AppUILanguage? = nil + ) -> String { + let id = PolishScenarioCatalog.resolve(scenarioId).id + let lang = uiLanguage ?? AppGroupStore().uiLanguage + let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) + return isChineseNative + ? chinese(id: id, uiLanguage: lang) + : english(id: id, uiLanguage: lang) + } + + // MARK: - Chinese directives + + private static func chinese(id: String, uiLanguage: AppUILanguage) -> String { + let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans" + switch id { + case "work": + return """ + 场景:工作沟通(邮件、钉钉、Slack)。 + 格式(必须): + - 若有 2 个及以上独立事项/请求/问题,必须用 markdown「- 」列表,每条一行;禁止揉进一段。 + - 仅 1 件事:可用 1~2 句短段落;必要时「称呼 + 正文」。 + - 每条 action 清晰;称呼得体;不过度敬语。 + 允许:为列表组织内容,不必强行压成单句。 + 禁止:把多项内容合并成一个长句或一整段散文。 + """ + case "todo": + return """ + 场景:TODO/备忘清单。 + 格式(必须): + - 输出必须是 markdown「- 」列表,每条一行。 + - 每条以动词开头;一条一事;不写称呼、不写解释、不扩写。 + 禁止:段落 prose、寒暄、背景说明。 + """ + case "social_lifestyle": + if englishPlatformNames { + return """ + 场景:社交网络生活分享帖(Social Network)。 + 格式: + - 内容≥2 句时必须用空行分段;第一人称;可读性强。 + - 可适度 emoji;不写广告腔;不编造体验。 + """ + } + return """ + 场景:小红书生活分享帖。 + 格式: + - 内容≥2 句时必须用空行分段;第一人称;可读性强。 + - 可适度 emoji 与语气词;不写广告腔;不编造体验。 + """ + case "social_short": + if englishPlatformNames { + return """ + 场景:Instagram 短 caption。 + 格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。 + """ + } + return """ + 场景:微博短帖。 + 格式:句子短、开头抓人、信息密度高;控制总长度;不臆测标签或热点。 + """ + case "goofy": + return """ + 场景:轻松聊天(逗比风格)。 + 格式:自然短句;措辞略俏皮。 + 禁止:新增情节、编段子、捏造态度;严肃内容(请假/道歉/投诉)不要强行搞笑。 + """ + case "document": + return """ + 场景:文档/长文笔记。 + 格式: + - 完整句;≥2 个主题时用空行分段。 + - 枚举或步骤用 markdown「- 」列表;可用 `##` 小标题(仅当内容够长)。 + - 少网络用语;比工作沟通更适合长文叙述。 + """ + case "daily_chat", PolishScenarioCatalog.customId: + fallthrough + default: + return """ + 场景:日常聊天(IM/私聊)。 + 格式:自然短句,像真人发消息;标点轻松;可保留极少量口语感。 + 禁止:公文腔、报告体、强行列表(除非口述本身在枚举)。 + """ + } + } + + // MARK: - English directives + + private static func english(id: String, uiLanguage: AppUILanguage) -> String { + let englishPlatformNames = uiLanguage.resolvedLanguageCode() != "zh-Hans" + switch id { + case "work": + return """ + Scenario: workplace message (email, Slack, Teams). + Format (required): + - If there are 2+ distinct items/requests/questions, you MUST use markdown "- " bullets, one per line; never merge into one paragraph. + - Single item only: 1–2 short sentences; optional greeting + body. + - Clear action per item; polite but not overly formal. + Allowed: list layout instead of forcing a single dense paragraph. + Forbidden: cramming multiple points into one long sentence or prose block. + """ + case "todo": + return """ + Scenario: TODO / checklist note. + Format (required): + - Output MUST be markdown "- " bullets, one item per line. + - Each line starts with a verb; one task per line; no greeting, no explanation. + Forbidden: prose paragraphs, filler, background context. + """ + case "social_lifestyle": + if englishPlatformNames { + return """ + Scenario: social network lifestyle post. + Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences. + """ + } + return """ + Scenario: Xiaohongshu-style lifestyle share. + Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences. + """ + case "social_short": + if englishPlatformNames { + return """ + Scenario: short Instagram caption. + Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends. + """ + } + return """ + Scenario: Weibo-style short post. + Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends. + """ + case "goofy": + return """ + Scenario: playful chat (goofy tone). + Format: natural short sentences; slightly witty wording only. + Forbidden: new facts, invented jokes, forced humor on serious topics (leave/apology/complaint). + """ + case "document": + return """ + Scenario: document / long-form notes. + Format: complete sentences; blank lines between topics when ≥2 themes; use "- " bullets for steps/enumerations; `##` headings only when content is long enough; minimal slang. + """ + case "daily_chat", PolishScenarioCatalog.customId: + fallthrough + default: + return """ + Scenario: everyday chat (IM/DM). + Format: natural short sentences like texting; relaxed punctuation; very light colloquial tone ok. + Forbidden: memo/report tone; forced bullets unless the speaker is enumerating. + """ + } + } +} diff --git a/OSGKeyboardShared/Services/TranslationPrompt.swift b/OSGKeyboardShared/Services/TranslationPrompt.swift index d43aa41..c42cc20 100644 --- a/OSGKeyboardShared/Services/TranslationPrompt.swift +++ b/OSGKeyboardShared/Services/TranslationPrompt.swift @@ -12,9 +12,8 @@ // 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". +// Scenario output format (`ScenarioStyleDirective`) is appended so +// translate-and-polish honours the user's polish scenario choice. import Foundation @@ -26,36 +25,53 @@ public enum TranslationPrompt { /// - 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 { + /// - scenarioId: active polish scenario; format rules are shared + /// with the polish-only path via `ScenarioStyleDirective`. + /// - uiLanguage: host-app UI language (platform label variants). + public static func make( + target: TranslationLanguage, + providerId: String, + scenarioId: String = PolishScenarioCatalog.defaultId, + uiLanguage: AppUILanguage? = nil + ) -> String { let isChineseNative = ["zhipu", "moonshot", "qwen", "deepseek"].contains(providerId) - return isChineseNative ? chinesePrompt(target: target) : englishPrompt(target: target) + let directive = ScenarioStyleDirective.make( + scenarioId: scenarioId, + providerId: providerId, + uiLanguage: uiLanguage + ) + return isChineseNative + ? chinesePrompt(target: target, directive: directive) + : englishPrompt(target: target, directive: directive) } // MARK: - Chinese prompt (for DeepSeek / Qwen / GLM / Moonshot) - private static func chinesePrompt(target: TranslationLanguage) -> String { + private static func chinesePrompt(target: TranslationLanguage, directive: String) -> String { """ 你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述: 1) 先识别原话的主要语言(若不确定则按用户给定的方向处理); 2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测; 3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然; - 4) 保留枚举结构(第一…第二…),使用「\(target.promptLanguageName)」的列表惯例; - 5) 简洁,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个); + 5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍; 6) 只输出译文正文,不要解释、不要加引号、不要前缀"以下是翻译"。 + \(directive) """ } // MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese) - private static func englishPrompt(target: TranslationLanguage) -> String { + private static func englishPrompt(target: TranslationLanguage, directive: String) -> 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); + 4) Stay concise; if the scenario below does not require lists/sections, do not exceed 1.5x the spoken length; drop filler words (um, uh, like); + 5) If the scenario requires bullets or paragraph breaks, use that layout in the translation; total length may be up to 2x when listing; 6) Output ONLY the translation. No quotes, no preamble, no explanation. + \(directive) """ } -} \ No newline at end of file +} diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index df42de5..83c252f 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -5,7 +5,7 @@ "engine.asr.appleSpeech" = "Apple SpeechAnalyzer"; /* v0.2.0: flow-level warnings surfaced alongside the final transcript. */ -"flow.warning.cloudPolishMissingKey" = "Cloud polish is on, but no API key is set. Inserted the raw ASR transcript — fill in your DeepSeek key in Settings to enable polish."; +"flow.warning.cloudPolishMissingKey" = "Cloud polish/translation needs a DeepSeek API key. Inserted raw ASR text — set PreconfiguredKeys.deepseek in the project (local engine) or API key in Settings (cloud engine)."; /* LLM providers */ "provider.openai" = "OpenAI"; @@ -30,3 +30,21 @@ "error.asr.formatUnsupported" = "This device does not support the required audio format."; "error.asr.noSpeech" = "No speech detected. Please try again."; "error.asr.chunkFailed" = "Segment %lld failed: %@"; + +/* Polish scenarios */ +"polishScenario.daily_chat" = "Daily Chat"; +"polishScenario.social_lifestyle" = "Social Network"; +"polishScenario.social_short" = "Instagram"; +"polishScenario.goofy" = "Goofy"; +"polishScenario.work" = "Work"; +"polishScenario.document" = "Document"; +"polishScenario.todo" = "TODO"; +"polishScenario.custom" = "Custom"; +"polishScenario.chip.daily_chat" = "Chat"; +"polishScenario.chip.social_lifestyle" = "Social"; +"polishScenario.chip.social_short" = "IG"; +"polishScenario.chip.goofy" = "Goofy"; +"polishScenario.chip.work" = "Work"; +"polishScenario.chip.document" = "Doc"; +"polishScenario.chip.todo" = "TODO"; +"polishScenario.chip.custom" = "Custom"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index a3f806b..883801d 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -5,7 +5,7 @@ "engine.asr.appleSpeech" = "Apple 语音识别"; /* v0.2.0: flow-level warnings surfaced alongside the final transcript. */ -"flow.warning.cloudPolishMissingKey" = "已开启云端润色但未填写 API Key,本次以原始识别结果插入。请在设置中填入 DeepSeek API Key 以启用润色。"; +"flow.warning.cloudPolishMissingKey" = "云端润色/翻译需要 DeepSeek API Key,本次已插入原始识别结果。本地引擎请在 PreconfiguredKeys.swift 配置;云端引擎请在设置中填写 API Key。"; /* LLM providers */ "provider.openai" = "OpenAI"; @@ -30,3 +30,21 @@ "error.asr.formatUnsupported" = "当前设备不支持该语音输入格式。"; "error.asr.noSpeech" = "未识别到语音内容,请重试。"; "error.asr.chunkFailed" = "第 %lld 段识别失败:%@"; + +/* 润色场景 */ +"polishScenario.daily_chat" = "日常聊天"; +"polishScenario.social_lifestyle" = "小红书"; +"polishScenario.social_short" = "微博"; +"polishScenario.goofy" = "逗比"; +"polishScenario.work" = "工作"; +"polishScenario.document" = "文档写作"; +"polishScenario.todo" = "TODO 记录"; +"polishScenario.custom" = "自定义"; +"polishScenario.chip.daily_chat" = "聊天"; +"polishScenario.chip.social_lifestyle" = "小红书"; +"polishScenario.chip.social_short" = "微博"; +"polishScenario.chip.goofy" = "逗比"; +"polishScenario.chip.work" = "工作"; +"polishScenario.chip.document" = "文档"; +"polishScenario.chip.todo" = "TODO"; +"polishScenario.chip.custom" = "自定义"; diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index 65cd5a2..79c1a80 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -341,6 +341,196 @@ final class LLMClientTests: XCTestCase { let calls = await counter.value() XCTAssertEqual(calls, 0) } + + /// Local engine pins DeepSeek — cloud-provider URL/model in App Group + /// must not leak into the LLM request (regression: Qwen URL + DeepSeek key → 401). + func testResolveLLMEndpointUsesPresetWhenProviderPinned() { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("qwen", forKey: "config.providerId") + defaults.set( + "https://dashscope.aliyuncs.com/compatible-mode/v1", + forKey: "config.baseURL" + ) + defaults.set("qwen-plus", forKey: "config.model") + + let store = AppGroupStore(defaults: defaults) + let deepseekPreset = LLMProvider.provider(id: "deepseek") + let pinned = PolishingService.resolveLLMEndpoint( + store: store, + preset: deepseekPreset, + providerIdOverride: "deepseek" + ) + XCTAssertEqual(pinned.baseURL, deepseekPreset.defaultBaseURL) + XCTAssertEqual(pinned.model, deepseekPreset.defaultModel) + + let qwenPreset = LLMProvider.provider(id: "qwen") + let cloud = PolishingService.resolveLLMEndpoint( + store: store, + preset: qwenPreset, + providerIdOverride: nil + ) + XCTAssertEqual( + cloud.baseURL, + "https://dashscope.aliyuncs.com/compatible-mode/v1", + "cloud engine must keep user base URL" + ) + XCTAssertEqual(cloud.model, "qwen-plus", "cloud engine must keep user model") + } + + func testTranslationChipVisibleWithoutTargetLocale() { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("cloud", forKey: "config.engineMode") + defaults.set(TranslationLanguageCatalog.offLocaleId, forKey: "config.translationTargetLocaleId") + let cloudStore = AppGroupStore(defaults: defaults) + XCTAssertTrue(cloudStore.isTranslationChipVisible) + XCTAssertFalse(cloudStore.isTranslationEffective) + + defaults.set("local", forKey: "config.engineMode") + defaults.set(true, forKey: "config.localModeCloudPolishEnabled") + let localPolishOn = AppGroupStore(defaults: defaults) + XCTAssertTrue(localPolishOn.isTranslationChipVisible) + XCTAssertFalse(localPolishOn.isTranslationEffective) + + defaults.set(false, forKey: "config.localModeCloudPolishEnabled") + let localPolishOff = AppGroupStore(defaults: defaults) + XCTAssertFalse(localPolishOff.isTranslationChipVisible) + } + + /// Local engine with translation enabled must invoke the LLM even + /// when the cloud-polish toggle is off. + func testPolisherSkipsLLMWhenLocalCloudPolishOffEvenWithTranslation() async throws { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("local", forKey: "config.engineMode") + defaults.set("en", forKey: "config.translationTargetLocaleId") + defaults.set(false, forKey: "config.localModeCloudPolishEnabled") + + let counter = CallCounter() + let countingClient = CountingLLMClient(counter: counter) { _, _ in + XCTFail("cloud LLMClient must not run when local cloud polish is off") + return "" + } + + let store = AppGroupStore(defaults: defaults) + XCTAssertFalse(store.shouldRunCloudLLMStep) + + let polisher = PolishingService( + store: store, + client: countingClient, + timeout: 1 + ) + + let result = try await polisher.polish( + " 你好 ", + mode: .translate(targetLocaleId: "en"), + providerIdOverride: "deepseek" + ) + XCTAssertEqual(result, "你好") + let calls = await counter.value() + XCTAssertEqual(calls, 0) + } + + /// Local engine with cloud polish + translation enabled invokes LLM. + func testPolisherTranslatesWhenLocalEngineTranslationEnabled() async throws { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("local", forKey: "config.engineMode") + defaults.set("en", forKey: "config.translationTargetLocaleId") + defaults.set(true, forKey: "config.localModeCloudPolishEnabled") + + let counter = CallCounter() + let countingClient = CountingLLMClient(counter: counter) { raw, prompt in + XCTAssertEqual(raw, "你好") + XCTAssertTrue(prompt.contains("English"), "translate prompt should target English") + return "Hello" + } + + let store = AppGroupStore(defaults: defaults) + let polisher = PolishingService( + store: store, + client: countingClient, + timeout: 1 + ) + + let result = try await polisher.polish( + " 你好 ", + mode: .translate(targetLocaleId: "en"), + providerIdOverride: "deepseek" + ) + XCTAssertEqual(result, "Hello") + let calls = await counter.value() + XCTAssertEqual(calls, 1) + } + + func testResolvedPolishSystemPromptUsesWorkScenario() { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("work", forKey: "config.polishScenarioId") + let store = AppGroupStore(defaults: defaults) + let prompt = store.resolvedPolishSystemPrompt(providerId: "openai") + XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace")) + } + + func testCustomPolishScenarioUsesStoredSystemPrompt() { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set(PolishScenarioCatalog.customId, forKey: "config.polishScenarioId") + defaults.set("MY CUSTOM PROMPT", forKey: "config.systemPrompt") + let store = AppGroupStore(defaults: defaults) + XCTAssertEqual(store.resolvedPolishSystemPrompt(), "MY CUSTOM PROMPT") + } + + func testPolishScenarioChipVisibleWhenCloudPolishEnabledLocally() { + let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + defaults.set("local", forKey: "config.engineMode") + defaults.set(true, forKey: "config.localModeCloudPolishEnabled") + let store = AppGroupStore(defaults: defaults) + XCTAssertTrue(store.isPolishScenarioChipVisible) + } + + func testTranslationPromptIncludesWorkScenarioBullets() { + let prompt = TranslationPrompt.make( + target: TranslationLanguageCatalog.resolve("en"), + providerId: "openai", + scenarioId: "work" + ) + XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown")) + XCTAssertTrue(prompt.localizedCaseInsensitiveContains("workplace")) + } + + func testWorkScenarioPolishPromptRequiresBullets() { + let prompt = ScenarioPrompt.make(scenarioId: "work", providerId: "openai") + XCTAssertTrue(prompt.localizedCaseInsensitiveContains("MUST use markdown")) + } + + func testTodoScenarioPolishPromptRequiresChecklist() { + let prompt = ScenarioPrompt.make(scenarioId: "todo", providerId: "deepseek") + XCTAssertTrue(prompt.contains("必须是 markdown")) + } } // MARK: - Test helpers diff --git a/project.yml b/project.yml index f556914..11b3547 100644 --- a/project.yml +++ b/project.yml @@ -39,8 +39,8 @@ settings: GENERATE_INFOPLIST_FILE: NO ENABLE_MODULE_VERIFIER: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 - MARKETING_VERSION: "0.2.0" - CURRENT_PROJECT_VERSION: "4" + MARKETING_VERSION: "0.3.0" + CURRENT_PROJECT_VERSION: "5" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target From bc77cabb6222a6e44b836af28f928f16d1282e90 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:01:54 +0800 Subject: [PATCH 8/8] feat: keyboard bottom-row layout, handedness preference, and screen wake lock Rework the action cluster to a mic-above-bottom-row layout, add left/right handedness setting that swaps delete and return, and keep the screen awake during Flow recording sessions. --- OSGKeyboard/Services/FlowSessionManager.swift | 3 + OSGKeyboard/Utilities/ScreenWakeLock.swift | 26 ++ OSGKeyboard/Views/SettingsView.swift | 47 ++++ OSGKeyboard/en.lproj/Localizable.strings | 4 + OSGKeyboard/zh-Hans.lproj/Localizable.strings | 4 + OSGKeyboardExt/KeyboardViewController.swift | 4 + .../Services/AppGroupPersistor.swift | 2 + .../Utilities/ExtensionScreenWakeLock.swift | 45 ++++ OSGKeyboardExt/Views/KeyboardRootView.swift | 159 ++++++------- OSGKeyboardExt/Views/RecordButton.swift | 22 +- .../Views/ToolbarActionButtons.swift | 224 ++++++++++++++++++ .../Models/FlowUtteranceChunkConfig.swift | 2 +- .../Models/HandednessPreference.swift | 29 +++ OSGKeyboardShared/Models/ProviderConfig.swift | 13 + .../Services/AppGroupStore.swift | 11 + .../Services/FlowSessionKeys.swift | 4 +- .../Services/KeyboardState.swift | 2 + project.yml | 4 +- 18 files changed, 501 insertions(+), 104 deletions(-) create mode 100644 OSGKeyboard/Utilities/ScreenWakeLock.swift create mode 100644 OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift create mode 100644 OSGKeyboardExt/Views/ToolbarActionButtons.swift create mode 100644 OSGKeyboardShared/Models/HandednessPreference.swift diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index be493f0..9c7c95f 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -140,6 +140,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.writeHeartbeat() FlowSessionDarwin.postSessionChanged() isActive = true + ScreenWakeLock.acquire() if let expires = FlowSessionBridge.sessionExpiresAt() { sessionExpiresAt = Date(timeIntervalSince1970: expires) } @@ -187,6 +188,7 @@ final class FlowSessionManager: ObservableObject { capture.stop() endBackgroundKeepAlive() + ScreenWakeLock.release() sessionASR = nil FlowSessionBridge.markSessionInactive() FlowSessionDarwin.postSessionChanged() @@ -321,6 +323,7 @@ final class FlowSessionManager: ObservableObject { FlowSessionBridge.markSessionActive(duration: duration) FlowSessionDarwin.postSessionChanged() isActive = true + ScreenWakeLock.acquire() sessionExpiresAt = Date().addingTimeInterval(duration) startHeartbeat() diff --git a/OSGKeyboard/Utilities/ScreenWakeLock.swift b/OSGKeyboard/Utilities/ScreenWakeLock.swift new file mode 100644 index 0000000..4be52fb --- /dev/null +++ b/OSGKeyboard/Utilities/ScreenWakeLock.swift @@ -0,0 +1,26 @@ +// ScreenWakeLock.swift +// OSGKeyboard · Main App +// +// Reference-counted idle-timer disable for Flow session ownership. + +import UIKit + +@MainActor +enum ScreenWakeLock { + private static var holdCount = 0 + + static func acquire() { + holdCount += 1 + if holdCount == 1 { + UIApplication.shared.isIdleTimerDisabled = true + } + } + + static func release() { + guard holdCount > 0 else { return } + holdCount -= 1 + if holdCount == 0 { + UIApplication.shared.isIdleTimerDisabled = false + } + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 46cd7db..947161e 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -78,6 +78,7 @@ struct SettingsView: View { localEngineSettingsSection } if presentation == .tab { + preferencesSection footerLinks } } @@ -253,6 +254,27 @@ struct SettingsView: View { dynamicLocales = entries } + // MARK: - Preferences (tab settings only) + + private var preferencesSection: some View { + VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) { + sectionHeader("settings.preferences.title") + VStack(spacing: 0) { + HandednessPickerRow( + selection: Binding( + get: { config.handednessPreference }, + set: { config.handednessPreference = $0 } + ) + ) + } + .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Radius.xl, style: .continuous) + .stroke(palette.divider, lineWidth: 0.5) + ) + } + } + // MARK: - Footer links (tab settings only) private var footerLinks: some View { @@ -366,6 +388,31 @@ struct SettingsView: View { } } +// MARK: - Handedness picker row + +private struct HandednessPickerRow: View { + @Binding var selection: HandednessPreference + + private var options: [(id: String, label: String)] { + HandednessPreference.allCases.map { preference in + (preference.rawValue, AppL10n.string(preference.labelKey)) + } + } + + var body: some View { + PickerRow( + title: AppL10n.string("settings.handedness.title"), + options: options, + selection: Binding( + get: { selection.rawValue }, + set: { newValue in + selection = HandednessPreference(rawValue: newValue) ?? .left + } + ) + ) + } +} + // MARK: - Picker row (generic) private struct PickerRow: View { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 730c1b2..816a638 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -135,6 +135,10 @@ "settings.systemPrompt.edit" = "Edit system prompt"; "settings.systemPrompt.hint" = "Used by the cloud LLM when polishing your dictation. Local engine skips this step."; "settings.about.title" = "About"; +"settings.preferences.title" = "Preferences"; +"settings.handedness.title" = "Handedness"; +"settings.handedness.left" = "Left hand"; +"settings.handedness.right" = "Right hand"; "settings.systemPrompt.reset" = "Reset"; "settings.asrLocale" = "ASR locale"; "settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 970448d..5d1f2df 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -135,6 +135,10 @@ "settings.systemPrompt.edit" = "编辑系统提示"; "settings.systemPrompt.hint" = "云端润色时使用。本地识别模式不会用到此提示词。"; "settings.about.title" = "关于"; +"settings.preferences.title" = "偏好设置"; +"settings.handedness.title" = "握持偏好"; +"settings.handedness.left" = "左手"; +"settings.handedness.right" = "右手"; "settings.systemPrompt.reset" = "重置"; "settings.asrLocale" = "识别语言"; "settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index 2472391..ddbb7f8 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -115,6 +115,7 @@ public final class KeyboardViewController: UIInputViewController { if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult { return } + ExtensionScreenWakeLock.releaseAll() cancelPipeline() } @@ -396,6 +397,7 @@ public final class KeyboardViewController: UIInputViewController { isFlowRecording = false stopUtteranceCountdown() + ExtensionScreenWakeLock.release() FlowSessionBridge.setRecordingState(.stopped) state.phase = .processing state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing") @@ -412,6 +414,7 @@ public final class KeyboardViewController: UIInputViewController { isFlowRecording = true state.lastTranscript = "" state.phase = .recording + ExtensionScreenWakeLock.acquire(from: view) startUtteranceCountdown() startFlowLevelWatchdog() debug("startFlowRecording") @@ -574,6 +577,7 @@ public final class KeyboardViewController: UIInputViewController { if isFlowRecording || isPendingFlowStart { if isFlowRecording { FlowSessionBridge.setRecordingState(.aborted) + ExtensionScreenWakeLock.release() } isFlowRecording = false isPendingFlowStart = false diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 1d8e08a..5021bcf 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -41,6 +41,7 @@ public struct AppGroupPersistor { // the keyboard stays open. state.translationTargetLocaleId = store.translationTargetLocaleId state.polishScenarioId = store.polishScenarioId + state.handednessPreference = store.handednessPreference state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled // v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that // into the State flags so downstream consumers see the same @@ -99,6 +100,7 @@ public struct AppGroupPersistor { if !shouldProtectScenario { state.polishScenarioId = store.polishScenarioId } + state.handednessPreference = store.handednessPreference // 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. diff --git a/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift b/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift new file mode 100644 index 0000000..0cfe779 --- /dev/null +++ b/OSGKeyboardExt/Utilities/ExtensionScreenWakeLock.swift @@ -0,0 +1,45 @@ +// ExtensionScreenWakeLock.swift +// OSGKeyboard · Keyboard Extension +// +// Keyboard extensions cannot call `UIApplication.shared`; walk the +// responder chain to reach the host app's `UIApplication` instead. + +import UIKit + +@MainActor +enum ExtensionScreenWakeLock { + private static var holdCount = 0 + private static weak var capturedApplication: UIApplication? + + static func acquire(from responder: UIResponder) { + holdCount += 1 + if holdCount == 1 { + capturedApplication = findApplication(from: responder) + capturedApplication?.isIdleTimerDisabled = true + } + } + + static func release() { + guard holdCount > 0 else { return } + holdCount -= 1 + if holdCount == 0 { + capturedApplication?.isIdleTimerDisabled = false + capturedApplication = nil + } + } + + static func releaseAll() { + holdCount = 0 + capturedApplication?.isIdleTimerDisabled = false + capturedApplication = nil + } + + private static func findApplication(from responder: UIResponder) -> UIApplication? { + var current: UIResponder? = responder + while let node = current { + if let application = node as? UIApplication { return application } + current = node.next + } + return nil + } +} diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 4469abc..f682432 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -11,8 +11,8 @@ // │ [polish] [中] ⚙ │ ← header band (top) // │ (transcript preview) │ // │ ┊ │ -// │ (⌫) ◯ mic (↩) │ ← action cluster: -// │ (space) │ centred below header +// │ ◯ mic (centred) │ ← action cluster: +// │ [delete] [ space ] [return] │ mic + bottom row // │ ┊ │ // └───────────────────────────────────────────┘ @@ -20,36 +20,39 @@ import SwiftUI import OSGKeyboardShared private enum KeyboardLayoutMetrics { - static let sideActionButtonSize: CGFloat = 53 - static let sideActionIconSize: CGFloat = 19 - static let sideSpaceBarWidth: CGFloat = 19 - static let micFlankMinSpacing: CGFloat = 36 - static let sideActionStackSpacing: CGFloat = 16 + static let micSize: CGFloat = 121 + static let micToButtonGap: CGFloat = 8 + static let bottomActionRowHeight: CGFloat = 48 + static let bottomActionFixedWidth: CGFloat = 86 + static let bottomActionSpacing: CGFloat = Spacing.xs /// Gap between the top chip row and the transcript / hint line (4 pt → 8 pt, +100%). static let topBarToTranscriptSpacing: CGFloat = Spacing.xs - /// Outer inset for delete / return·space from screen edges (8 pt → 24 pt, +200%). + /// Outer inset for the bottom action row from screen edges (8 pt → 24 pt, +200%). static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3 // MARK: - Content-driven keyboard height (single source of truth) static let outerPaddingTop: CGFloat = 2 - static let outerPaddingBottom: CGFloat = 6 + static let outerPaddingBottom: CGFloat = 1 static let topBarHeight: CGFloat = 38 static let transcriptLineHeight: CGFloat = 22 - static let actionClusterHeight: CGFloat = 132 - /// Fixed breathing room above/below the mic row (not flexible Spacers). - static let actionClusterVerticalGap: CGFloat = Spacing.md + /// mic (121) + gap (8) + bottom row (48) = 177 pt + static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight + /// Gap between transcript line and mic (−30% from former 16 pt). + static let actionClusterTopGap: CGFloat = Spacing.md * 0.7 + /// Minimal gap below the bottom action row. + static let actionClusterBottomGap: CGFloat = Spacing.xs / 2 static var headerBandHeight: CGFloat { topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight } - /// 2 + 68 + 16 + 132 + 16 + 6 = 240 pt + /// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt static var totalHeight: CGFloat { outerPaddingTop + headerBandHeight - + actionClusterVerticalGap + + actionClusterTopGap + actionClusterHeight - + actionClusterVerticalGap + + actionClusterBottomGap + outerPaddingBottom } } @@ -76,13 +79,13 @@ public struct KeyboardRootView: View { headerBand Color.clear - .frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap) + .frame(height: KeyboardLayoutMetrics.actionClusterTopGap) micActionRow .frame(height: KeyboardLayoutMetrics.actionClusterHeight) Color.clear - .frame(height: KeyboardLayoutMetrics.actionClusterVerticalGap) + .frame(height: KeyboardLayoutMetrics.actionClusterBottomGap) } .padding(.top, KeyboardLayoutMetrics.outerPaddingTop) .padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom) @@ -152,33 +155,29 @@ public struct KeyboardRootView: View { // MARK: - Action cluster - /// Delete (left), mic (centre), return + space stacked on the right. - /// Fixed vertical gaps in `body` keep the cluster centred without - /// flexible Spacers consuming extra keyboard height. + /// Mic centred above a bottom row: delete · space · return (or swapped). private var micActionRow: some View { - HStack(alignment: .center, spacing: 0) { - CircularToolbarButton(systemName: "delete.left", label: "delete") { - state.deleteBackward() - } - - Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing) + let editingBlocked = voiceInputBlocksEditing + let swapKeys = state.handednessPreference.swapsActionKeys + return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) { RecordButton( phase: buttonPhase, level: state.level, remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil, onToggle: state.tapMic ) - .frame(width: 132, height: 132) + .frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize) - Spacer(minLength: KeyboardLayoutMetrics.micFlankMinSpacing) - - VStack(spacing: KeyboardLayoutMetrics.sideActionStackSpacing) { - CircularToolbarButton(systemName: "return", label: "newline") { - state.insertNewline() - } - CircularToolbarButton(spaceStyle: true, label: "space") { - state.insertSpace() + HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) { + if swapKeys { + bottomReturnButton(disabled: editingBlocked) + bottomSpaceButton(disabled: editingBlocked) + bottomDeleteButton(disabled: editingBlocked) + } else { + bottomDeleteButton(disabled: editingBlocked) + bottomSpaceButton(disabled: editingBlocked) + bottomReturnButton(disabled: editingBlocked) } } } @@ -186,6 +185,43 @@ public struct KeyboardRootView: View { .frame(maxWidth: .infinity) } + private func bottomDeleteButton(disabled: Bool) -> some View { + RepeatingDeleteButton(disabled: disabled) { + state.deleteBackward() + } + .frame( + width: KeyboardLayoutMetrics.bottomActionFixedWidth, + height: KeyboardLayoutMetrics.bottomActionRowHeight + ) + } + + private func bottomSpaceButton(disabled: Bool) -> some View { + RectangularToolbarButton(spaceStyle: true, label: "space", disabled: disabled) { + state.insertSpace() + } + .frame(height: KeyboardLayoutMetrics.bottomActionRowHeight) + } + + private func bottomReturnButton(disabled: Bool) -> some View { + RectangularToolbarButton(systemName: "return", label: "newline", disabled: disabled) { + state.insertNewline() + } + .frame( + width: KeyboardLayoutMetrics.bottomActionFixedWidth, + height: KeyboardLayoutMetrics.bottomActionRowHeight + ) + } + + /// Option C: block typing keys during the full voice-input pipeline. + private var voiceInputBlocksEditing: Bool { + switch state.phase { + case .requestingPermissions, .recording, .processing: + return true + case .idle, .error, .denied: + return false + } + } + private var buttonPhase: RecordButton.Phase { switch state.phase { case .idle: return .idle @@ -337,59 +373,6 @@ private struct TranscriptLine: View { } } -// MARK: - Circular toolbar button - -private struct CircularToolbarButton: View { - @Environment(\.colorScheme) private var colorScheme - @Environment(\.themePalette) private var palette: ThemePalette - - let systemName: String? - let spaceStyle: Bool - let label: String - let action: () -> Void - - init(systemName: String, label: String, action: @escaping () -> Void) { - self.systemName = systemName - self.spaceStyle = false - self.label = label - self.action = action - } - - init(spaceStyle: Bool, label: String, action: @escaping () -> Void) { - self.systemName = nil - self.spaceStyle = spaceStyle - self.label = label - self.action = action - } - - var body: some View { - Button(action: action) { - Group { - if spaceStyle { - Capsule() - .fill(palette.textPrimary) - .frame(width: KeyboardLayoutMetrics.sideSpaceBarWidth, height: 3) - } else if let systemName { - Image(systemName: systemName) - .font(.system(size: KeyboardLayoutMetrics.sideActionIconSize, weight: .medium)) - .foregroundStyle(palette.textPrimary) - } - } - .frame(width: KeyboardLayoutMetrics.sideActionButtonSize, height: KeyboardLayoutMetrics.sideActionButtonSize) - .background(sideButtonFill, in: Circle()) - .overlay(Circle().stroke(palette.dividerStrong, lineWidth: 0.5)) - } - .buttonStyle(.plain) - .accessibilityLabel(Text(label)) - } - - private var sideButtonFill: Color { - colorScheme == .dark - ? Color(red: 0.20, green: 0.20, blue: 0.22) - : palette.surfaceElevated - } -} - // MARK: - Cloud engine chip (cloud always ASR + LLM polish) private struct CloudEngineChip: View { diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index d589698..379b524 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -42,13 +42,13 @@ struct RecordButton: View { return remainingSeconds <= 10 } - /// Decorative rings are sized to stay inside the 132 pt frame applied + /// Decorative rings are sized to stay inside the 121 pt frame applied /// by `KeyboardRootView` so glow / breath animations are not clipped. private enum Layout { - static let disc: CGFloat = 104 - static let outerRing: CGFloat = 112 - static let breathRing: CGFloat = 108 - static let glow: CGFloat = 128 + static let disc: CGFloat = 95 + static let outerRing: CGFloat = 106 + static let breathRing: CGFloat = 100 + static let glow: CGFloat = 119 } var body: some View { @@ -65,8 +65,8 @@ struct RecordButton: View { RadialGradient( colors: [palette.recordRed.opacity(0.55), .clear], center: .center, - startRadius: 50, - endRadius: 100 + startRadius: 46, + endRadius: 92 ) ) .frame(width: Layout.glow, height: Layout.glow) @@ -93,10 +93,10 @@ struct RecordButton: View { switch phase { case .idle: Image(systemName: "mic.fill") - .font(.system(size: 38, weight: .medium)) + .font(.system(size: 36, weight: .medium)) .foregroundStyle(.white) case .recording: - VStack(spacing: 4) { + VStack(spacing: 3) { if let remainingSeconds { Text(formatRemaining(remainingSeconds)) .font(.system(size: 22, weight: .semibold, design: .rounded)) @@ -109,7 +109,7 @@ struct RecordButton: View { color: Color(red: 1.0, green: 0.78, blue: 0.78), active: true ) - .frame(width: 72, height: 32) + .frame(width: 73, height: 32) } .transition(.opacity) case .processing: @@ -119,7 +119,7 @@ struct RecordButton: View { .scaleEffect(2.5) case .error: Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 30, weight: .medium)) + .font(.system(size: 32, weight: .medium)) .foregroundStyle(palette.warning) } } diff --git a/OSGKeyboardExt/Views/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift new file mode 100644 index 0000000..16bca44 --- /dev/null +++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift @@ -0,0 +1,224 @@ +// ToolbarActionButtons.swift +// OSGKeyboard · Keyboard Extension +// +// Bottom-row action keys: repeating delete, space, and return. + +import SwiftUI +import UIKit +import OSGKeyboardShared + +// MARK: - Layout metrics + +private enum ToolbarButtonMetrics { + static let iconSize: CGFloat = 14 + static let cornerRadius: CGFloat = 12 + static let spaceBarCapsuleWidth: CGFloat = 31 + static let pressScale: CGFloat = 0.94 + static let pressOverlayOpacity: CGFloat = 0.18 +} + +// MARK: - Haptics + +private enum ToolbarHaptics { + @MainActor + static func tap() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } +} + +// MARK: - Press styling + +private struct ToolbarKeyPressStyle: ButtonStyle { + let cornerRadius: CGFloat + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .overlay { + if configuration.isPressed { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity)) + } + } + .scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + .sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in + pressed + } + } +} + +private struct ToolbarKeySurface: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.themePalette) private var palette + + let isPressed: Bool + let cornerRadius: CGFloat + @ViewBuilder let content: () -> Content + + var body: some View { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(buttonFill, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + } + .overlay { + if isPressed { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity)) + } + } + .scaleEffect(isPressed ? ToolbarButtonMetrics.pressScale : 1) + .animation(.easeOut(duration: 0.1), value: isPressed) + } + + private var buttonFill: Color { + let base = colorScheme == .dark + ? Color(red: 0.20, green: 0.20, blue: 0.22) + : palette.surfaceElevated + return isPressed ? base.opacity(0.82) : base + } +} + +// MARK: - Repeating delete + +/// Tap deletes once; hold repeats with tiered acceleration after 5 s. +struct RepeatingDeleteButton: View { + @Environment(\.themePalette) private var palette + + let disabled: Bool + let action: () -> Void + + @State private var isPressing = false + @State private var repeatTask: Task? + @State private var repeatStartedAt: Date? + + private let initialDelay: TimeInterval = 0.4 + private let normalInterval: TimeInterval = 0.08 + private let accelTier2: TimeInterval = 0.05 + private let accelTier3: TimeInterval = 0.03 + private let accelTier4: TimeInterval = 0.015 + + var body: some View { + ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) { + Image(systemName: "delete.left") + .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + } + .contentShape(Rectangle()) + .gesture(pressGesture) + .opacity(disabled ? 0.38 : 1) + .allowsHitTesting(!disabled) + .accessibilityLabel(Text("delete")) + .accessibilityAddTraits(.isButton) + } + + private var pressGesture: some Gesture { + DragGesture(minimumDistance: 0) + .onChanged { _ in + guard !disabled, !isPressing else { return } + isPressing = true + repeatStartedAt = Date() + ToolbarHaptics.tap() + action() + startRepeating() + } + .onEnded { _ in + stopRepeating() + } + } + + private func interval(for elapsed: TimeInterval) -> TimeInterval { + if elapsed < 5 { return normalInterval } + if elapsed < 8 { return accelTier2 } + if elapsed < 12 { return accelTier3 } + return accelTier4 + } + + private func startRepeating() { + repeatTask?.cancel() + repeatTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(initialDelay * 1_000_000_000)) + guard !Task.isCancelled, isPressing else { return } + let anchor = repeatStartedAt ?? Date() + while !Task.isCancelled, isPressing { + action() + let elapsed = Date().timeIntervalSince(anchor) + let wait = interval(for: elapsed) + try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000)) + } + } + } + + private func stopRepeating() { + isPressing = false + repeatStartedAt = nil + repeatTask?.cancel() + repeatTask = nil + } +} + +// MARK: - Rectangular toolbar button + +struct RectangularToolbarButton: View { + @Environment(\.themePalette) private var palette + + let systemName: String? + let spaceStyle: Bool + let label: String + let disabled: Bool + let action: () -> Void + + init(systemName: String, label: String, disabled: Bool = false, action: @escaping () -> Void) { + self.systemName = systemName + self.spaceStyle = false + self.label = label + self.disabled = disabled + self.action = action + } + + init(spaceStyle: Bool, label: String, disabled: Bool = false, action: @escaping () -> Void) { + self.systemName = nil + self.spaceStyle = spaceStyle + self.label = label + self.disabled = disabled + self.action = action + } + + var body: some View { + Button(action: action) { + Group { + if spaceStyle { + Capsule() + .fill(palette.textPrimary) + .frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3) + } else if let systemName { + Image(systemName: systemName) + .font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold)) + .foregroundStyle(palette.textPrimary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(keyBackground) + .overlay( + RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous) + .stroke(palette.dividerStrong, lineWidth: 0.5) + ) + } + .buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius)) + .disabled(disabled) + .opacity(disabled ? 0.38 : 1) + .accessibilityLabel(Text(label)) + } + + @Environment(\.colorScheme) private var colorScheme + + private var keyBackground: some View { + let fill = colorScheme == .dark + ? Color(red: 0.20, green: 0.20, blue: 0.22) + : palette.surfaceElevated + return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous) + .fill(fill) + } +} diff --git a/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift b/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift index 10c8505..39da178 100644 --- a/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift +++ b/OSGKeyboardShared/Models/FlowUtteranceChunkConfig.swift @@ -1,7 +1,7 @@ // FlowUtteranceChunkConfig.swift // OSGKeyboard · Shared // -// Chunking policy for pipelined Flow utterance ASR (up to 3 minutes). +// Chunking policy for pipelined Flow utterance ASR (up to 3.5 minutes). import Foundation diff --git a/OSGKeyboardShared/Models/HandednessPreference.swift b/OSGKeyboardShared/Models/HandednessPreference.swift new file mode 100644 index 0000000..1e6535c --- /dev/null +++ b/OSGKeyboardShared/Models/HandednessPreference.swift @@ -0,0 +1,29 @@ +// HandednessPreference.swift +// OSGKeyboard · Shared +// +// Which hand the user holds the phone with — controls bottom-row key order +// on the keyboard (delete ↔ return swap for right-handed use). + +import Foundation + +public enum HandednessPreference: String, CaseIterable, Identifiable, Sendable, Codable { + case left + case right + + public var id: String { rawValue } + + public var labelKey: String { + switch self { + case .left: return "settings.handedness.left" + case .right: return "settings.handedness.right" + } + } + + /// Right-handed preference places return on the left and delete on the right. + public var swapsActionKeys: Bool { self == .right } + + public static func fromStored(_ raw: String?) -> HandednessPreference { + guard let raw, let value = HandednessPreference(rawValue: raw) else { return .left } + return value + } +} diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index fb7f2e5..f77822f 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -52,6 +52,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { // "on" state during init, but new writes never touch the key. static let translationTargetLocaleId = "config.translationTargetLocaleId" static let polishScenarioId = "config.polishScenarioId" + static let handednessPreference = "config.handednessPreference" } @Published public var providerId: String { @@ -163,6 +164,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { AppGroupConfigDarwin.postConfigChanged() } } + /// Which hand the user holds the phone with — mirrors to the keyboard + /// extension so delete / return can swap on the bottom row. + @Published public var handednessPreference: HandednessPreference { + didSet { + defaults.set(handednessPreference.rawValue, forKey: Key.handednessPreference) + AppGroupConfigDarwin.postConfigChanged() + } + } /// Whether the pipeline should run translate-and-polish (not just /// polish). Cloud engine: any selected target locale. Local engine: @@ -299,6 +308,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { self.polishScenarioId = PolishScenarioCatalog.defaultId } } + self.handednessPreference = HandednessPreference.fromStored( + resolvedDefaults.string(forKey: Key.handednessPreference) + ) // Cloud no longer exposes off/transcribe; migrate legacy values. if self.engineMode == "cloud", self.modeId != "polish" { @@ -350,6 +362,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable { model = preset.defaultModel systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai") polishScenarioId = PolishScenarioCatalog.defaultId + handednessPreference = .left hasAcknowledgedCloudSharing = false } } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index 50fd422..89dc3e3 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -46,6 +46,7 @@ public struct AppGroupStore: @unchecked Sendable { // computed shim for source compatibility. static let translationTargetLocaleId = "config.translationTargetLocaleId" static let polishScenarioId = "config.polishScenarioId" + static let handednessPreference = "config.handednessPreference" } // MARK: - Reads @@ -133,6 +134,11 @@ public struct AppGroupStore: @unchecked Sendable { return PolishScenarioCatalog.resolve(stored ?? PolishScenarioCatalog.defaultId).id } + /// Bottom-row key order on the keyboard extension. + public var handednessPreference: HandednessPreference { + HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference)) + } + // MARK: - Writes public func setModeId(_ id: String) { @@ -184,6 +190,11 @@ public struct AppGroupStore: @unchecked Sendable { AppGroupConfigDarwin.postConfigChanged() } + public func setHandednessPreference(_ preference: HandednessPreference) { + defaults.set(preference.rawValue, forKey: Key.handednessPreference) + AppGroupConfigDarwin.postConfigChanged() + } + /// Whether ASR output should be sent through the cloud LLM step. /// Cloud engine: always. Local engine: only when cloud polish is /// enabled (translation is a sub-option of that step). diff --git a/OSGKeyboardShared/Services/FlowSessionKeys.swift b/OSGKeyboardShared/Services/FlowSessionKeys.swift index 3f0e976..3f78e93 100644 --- a/OSGKeyboardShared/Services/FlowSessionKeys.swift +++ b/OSGKeyboardShared/Services/FlowSessionKeys.swift @@ -24,8 +24,8 @@ public enum FlowSessionKeys { /// Default Flow session length when started from the keyboard. public static let defaultSessionDuration: TimeInterval = 480 - /// Maximum duration for a single keyboard utterance (3 minutes). - public static let maxUtteranceDuration: TimeInterval = 180 + /// Maximum duration for a single keyboard utterance (3.5 minutes). + public static let maxUtteranceDuration: TimeInterval = 210 /// Host polls for pipelined ASR drain after mic stop. Pipelining usually /// finishes most chunks during recording; this is a soft deadline before diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index ad79a53..40fd413 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -103,6 +103,8 @@ public final class KeyboardState: ObservableObject { /// v0.2.0: mirrored from App Group — local engine runs the cloud /// LLM step only when this is `true`. @Published public var localModeCloudPolishEnabled: Bool = false + /// Mirrored from App Group — swaps delete / return on the bottom row. + @Published public var handednessPreference: HandednessPreference = .left /// Whether translate-and-polish is actually armed for the current /// engine (local requires cloud polish + a target locale). public var isTranslationEffective: Bool { diff --git a/project.yml b/project.yml index 11b3547..05b7897 100644 --- a/project.yml +++ b/project.yml @@ -39,8 +39,8 @@ settings: GENERATE_INFOPLIST_FILE: NO ENABLE_MODULE_VERIFIER: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 - MARKETING_VERSION: "0.3.0" - CURRENT_PROJECT_VERSION: "5" + MARKETING_VERSION: "0.3.1" + CURRENT_PROJECT_VERSION: "6" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target