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.
This commit is contained in:
Rocky
2026-06-29 00:08:00 +08:00
parent 4fec0da7f0
commit 1bdb8824ac
32 changed files with 1247 additions and 340 deletions
+21
View File
@@ -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
+25 -5
View File
@@ -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")
+1 -27
View File
@@ -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 {
+13 -37
View File
@@ -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(
+59
View File
@@ -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)
}
}
+21 -35
View File
@@ -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 {
@@ -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)
+5
View File
@@ -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";
@@ -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" = "语音识别";
+105 -31
View File
@@ -74,24 +74,36 @@ public final class KeyboardViewController: UIInputViewController {
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>?
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") {
@@ -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)
}
}
+81 -114
View File
@@ -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 {
+13 -4
View File
@@ -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)
}
+59
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
+6 -2
View File
@@ -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";
@@ -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" = "关闭";
@@ -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)
}
}
+51 -14
View File
@@ -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
}
}
@@ -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
)
}
}
@@ -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
@@ -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<Box>.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<Box>.fromOpaque(token).release()
+23 -7
View File
@@ -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 = {}
@@ -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)."
}
}
}
@@ -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
}
}
@@ -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.
"""
}
@@ -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 : 12 ; +
- 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: 12 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.
"""
}
}
}
@@ -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)
"""
}
}
}
+19 -1
View File
@@ -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";
+19 -1
View File
@@ -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" = "自定义";
+190
View File
@@ -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
+2 -2
View File
@@ -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.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target