Merge pull request #3 from hkgood/feature/translation-polish-2

Feature/translation polish 2
This commit is contained in:
Rocky
2026-07-03 13:25:51 +08:00
committed by GitHub
43 changed files with 2490 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
+28 -5
View File
@@ -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()
@@ -560,11 +563,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 +584,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 +625,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")
@@ -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
}
}
}
+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 {
+27 -16
View File
@@ -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
@@ -57,25 +72,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 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: 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
+28
View File
@@ -756,8 +756,36 @@ private struct APISetupPage: View {
}
.padding(.horizontal, Spacing.lg)
}
if config.isPolishScenarioRowVisible {
postProcessingSection
.padding(.horizontal, Spacing.lg)
}
}
.padding(.bottom, Spacing.xxxl)
}
}
/// Polish scenario + optional translation target for cloud onboarding.
private var postProcessingSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
Text("settings.polishScenario.section")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(spacing: 0) {
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(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
}
@@ -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)
}
}
+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)
}
}
+80 -29
View File
@@ -62,23 +62,23 @@ 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
// 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" {
systemPromptLinkSection
if config.engineMode == "local" {
localEngineSettingsSection
}
if presentation == .tab {
preferencesSection
footerLinks
}
}
@@ -122,16 +122,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) {
if config.engineMode == "local" {
LocalModelsGroup(config: config)
Divider().background(palette.divider)
}
LocalePickerRow(
locales: effectiveLocales,
selection: Binding(
@@ -139,6 +135,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(
@@ -169,6 +182,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")
@@ -228,22 +254,22 @@ struct SettingsView: View {
dynamicLocales = entries
}
// MARK: - System prompt (cloud only)
// MARK: - Preferences (tab settings only)
private var systemPromptLinkSection: some View {
private var preferencesSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.systemPrompt.title")
sectionHeader("settings.preferences.title")
VStack(spacing: 0) {
NavigationLink {
SystemPromptSettingsView(config: config)
} label: {
footerNavigationRow(title: "settings.systemPrompt.edit")
}
.buttonStyle(.plain)
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
@@ -362,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 {
@@ -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)
@@ -0,0 +1,108 @@
// TranslationPickerRow.swift
// OSGKeyboard · Main App
//
// 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`.
//
// 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.
//
// 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 = <id>
//
// The pipeline (`PolishingService`) honors `.translate` on both
// engines when this row is visible no more "rejected mode" toast.
import SwiftUI
import OSGKeyboardShared
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 {
if isVisible {
HStack {
Text("settings.translation.afterPolish")
.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(currentIsOff ? palette.textSecondary : 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)
}
}
// MARK: - Selection plumbing
/// Currently selected id the picker always reads
/// `translationTargetLocaleId` directly (the previous
/// `translationEnabled` boolean is now derived from it).
private var currentSelectionId: String {
config.translationTargetLocaleId
}
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
}
/// 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) {
config.translationTargetLocaleId = language.id
}
}
+13
View File
@@ -108,6 +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";
@@ -116,6 +124,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.localEngine.title" = "Local engine";
"settings.language.subtitle.cloud" = "Recognition language and text processing mode.";
"settings.language.subtitle.local" = "Recognition language.";
"settings.mode.title" = "Mode";
@@ -126,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";
+14 -1
View File
@@ -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,6 +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" = "语音识别";
@@ -116,6 +124,7 @@
"settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪";
"settings.localModels.cloudPolish.title" = "识别后云端润色";
"settings.localModels.cloudPolish.subtitle" = "将识别文本发送给已配置的云端大模型(默认 DeepSeek)进行润色。仅在 iOS 语音识别效果不理想时(远场、噪声、方言)开启,需提前在设置中填入 DeepSeek API Key。";
"settings.localEngine.title" = "本地引擎";
"settings.language.subtitle.cloud" = "选择识别语言和文字处理模式。";
"settings.language.subtitle.local" = "选择识别语言。";
"settings.mode.title" = "模式";
@@ -126,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 — 始终端侧";
+131 -14
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()
}
@@ -103,11 +115,13 @@ public final class KeyboardViewController: UIInputViewController {
if isPendingFlowStart || isFlowRecording || isAwaitingFlowResult || awaitingDictationResult {
return
}
ExtensionScreenWakeLock.releaseAll()
cancelPipeline()
}
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded()
@@ -115,6 +129,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 +151,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() {
@@ -138,16 +171,52 @@ 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) }
// 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([
@@ -155,11 +224,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
@@ -200,8 +264,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()
@@ -313,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")
@@ -329,6 +414,7 @@ public final class KeyboardViewController: UIInputViewController {
isFlowRecording = true
state.lastTranscript = ""
state.phase = .recording
ExtensionScreenWakeLock.acquire(from: view)
startUtteranceCountdown()
startFlowLevelWatchdog()
debug("startFlowRecording")
@@ -491,6 +577,7 @@ public final class KeyboardViewController: UIInputViewController {
if isFlowRecording || isPendingFlowStart {
if isFlowRecording {
FlowSessionBridge.setRecordingState(.aborted)
ExtensionScreenWakeLock.release()
}
isFlowRecording = false
isPendingFlowStart = false
@@ -512,8 +599,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 {
@@ -524,12 +612,19 @@ 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 }
let polishMode = runtimeStore.polishModeForPipeline
let overrideProviderId = runtimeStore.polishProviderIdOverride
do {
let polished = try await self.polisher.polish(trimmed)
let polished = try await self.polisher.polish(
trimmed,
mode: polishMode,
providerIdOverride: overrideProviderId
)
self.textDocumentProxy.insertText(polished)
self.state.lastTranscript = ""
self.state.phase = .idle
@@ -620,6 +715,28 @@ public final class KeyboardViewController: UIInputViewController {
persistor.persist(localASRBackend: backend)
}
// MARK: - Translation persistence
/// 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
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") {
@@ -35,6 +35,14 @@ public struct AppGroupPersistor {
state.mode = .polish
state.engineMode = store.engineMode
state.localASRBackend = store.localASRBackend
// 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
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
// shape they did when the previous Qwen3 stack reported "ready".
@@ -70,11 +78,29 @@ 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
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
}
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.
@@ -105,4 +131,23 @@ public struct AppGroupPersistor {
guard AppGroup.isAvailable else { return }
AppGroupStore().setLocalASRBackend(localASRBackend)
}
/// 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)
}
public func persist(polishScenarioId: String) {
guard AppGroup.isAvailable else { return }
AppGroupStore().setPolishScenarioId(polishScenarioId)
}
}
@@ -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
}
}
+147 -193
View File
@@ -3,29 +3,58 @@
//
// 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 (centred) action cluster:
// [delete] [ space ] [return] mic + bottom row
//
//
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
/// Outer inset for delete / return·space from screen edges (8 pt 24 pt, +200%).
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 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 = 1
static let topBarHeight: CGFloat = 38
static let transcriptLineHeight: CGFloat = 22
/// 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 + 11.2 + 177 + 4 + 1 = 263.2 pt
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
+ actionClusterTopGap
+ actionClusterHeight
+ actionClusterBottomGap
+ outerPaddingBottom
}
}
public struct KeyboardRootView: View {
@@ -37,11 +66,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 +76,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.actionClusterTopGap)
micActionRow
.frame(height: KeyboardLayoutMetrics.actionClusterHeight)
Color.clear
.frame(height: KeyboardLayoutMetrics.actionClusterBottomGap)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.top, KeyboardLayoutMetrics.outerPaddingTop)
.padding(.bottom, KeyboardLayoutMetrics.outerPaddingBottom)
// chrome
.background(Color.clear)
.frame(height: Self.totalHeight)
@@ -64,6 +96,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,11 +125,20 @@ public struct KeyboardRootView: View {
} else {
CloudEngineChip()
}
if state.isPolishScenarioChipVisible {
ScenarioChip(state: state)
}
LocaleChip(localeId: state.localeId) { newId in
state.setLocale(newId)
}
// 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))
@@ -92,59 +153,31 @@ public struct KeyboardRootView: View {
.padding(.horizontal, Spacing.md)
}
// MARK: - Centre area
// MARK: - Action cluster
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)
}
/// 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.
/// 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)
}
}
}
@@ -152,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
@@ -175,19 +245,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
@@ -226,13 +296,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)
@@ -310,115 +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: - 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, 3)
.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 {
@@ -432,8 +386,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))
}
@@ -452,8 +406,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))
}
@@ -499,8 +453,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))
}
+19 -10
View File
@@ -42,11 +42,20 @@ struct RecordButton: View {
return remainingSeconds <= 10
}
/// 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 = 95
static let outerRing: CGFloat = 106
static let breathRing: CGFloat = 100
static let glow: CGFloat = 119
}
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)
@@ -56,11 +65,11 @@ struct RecordButton: View {
RadialGradient(
colors: [palette.recordRed.opacity(0.55), .clear],
center: .center,
startRadius: 50,
endRadius: 100
startRadius: 46,
endRadius: 92
)
)
.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()
@@ -84,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))
@@ -100,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:
@@ -110,12 +119,12 @@ 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)
}
}
}
.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)
}
}
@@ -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<Content: View>: 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<Void, Never>?
@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)
}
}
+137
View File
@@ -0,0 +1,137 @@
// 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.
//
// 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.
//
// 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, "" chip label (menu first row = "")
// on (any engine) accent fill, " EN" / " " style label
//
// Stays in the same visual family as `CloudEngineChip` / `LocaleChip`
// (Capsule + 28 pt min height + 6 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 {
// 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))
}
}
}
} 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 enabled = state.translationEnabled
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
Text(chipLabel(target: target, enabled: enabled))
Image(systemName: "chevron.down")
.font(.system(size: 8, weight: .bold))
}
.font(TypeStyle.caption2)
.foregroundStyle(foreground(enabled: enabled))
.padding(.horizontal, Spacing.xs + 2)
.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
/// 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.offMenu")
}
return language.nativeName
}
private func chipLabel(target: TranslationLanguage, enabled: Bool) -> String {
if !enabled {
return ExtL10n.string("keyboard.translation.chip")
}
// 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) -> Color {
if enabled { return palette.accent }
return palette.textPrimary
}
private func background(enabled: Bool) -> Color {
if enabled { return palette.accent.opacity(0.15) }
return palette.surfaceElevated
}
private func stroke(enabled: Bool) -> Color {
if enabled { return palette.accent.opacity(0.35) }
return palette.divider
}
}
+11 -1
View File
@@ -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";
@@ -177,6 +176,17 @@
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* 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";
"mode.transcribe" = "Transcribe";
+12 -2
View File
@@ -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" = "语言";
@@ -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" = "麦克风被拒绝";
@@ -177,6 +176,17 @@
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* 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" = "关闭";
"mode.transcribe" = "转写";
@@ -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
@@ -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
}
}
+9 -1
View File
@@ -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] = [
@@ -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)
}
}
+121 -1
View File
@@ -39,6 +39,20 @@ 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. 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`.
//
// 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"
static let polishScenarioId = "config.polishScenarioId"
static let handednessPreference = "config.handednessPreference"
}
@Published public var providerId: String {
@@ -109,12 +123,83 @@ 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 {
didSet { defaults.set(uiLanguage.rawValue, forKey: Key.uiLanguage) }
}
/// v0.2.1: whether to translate the transcript into
/// `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 `"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)
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()
}
}
/// 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:
/// only when cloud polish is also enabled.
public var isTranslationEffective: Bool {
guard translationEnabled else { return false }
if isLocalEngine { return localModeCloudPolishEnabled }
return true
}
/// 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 {
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 {
// Local engine (on-device ASR only) doesn't need an API key,
@@ -144,6 +229,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 {
@@ -193,6 +288,29 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.uiLanguage = AppUILanguage.fromStored(
resolvedDefaults.string(forKey: Key.uiLanguage)
)
// 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
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
}
}
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" {
@@ -243,6 +361,8 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
apiKey = ""
model = preset.defaultModel
systemPrompt = AppGroupStore.defaultSystemPrompt(for: "openai")
polishScenarioId = PolishScenarioCatalog.defaultId
handednessPreference = .left
hasAcknowledgedCloudSharing = false
}
}
@@ -0,0 +1,78 @@
// 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 {
/// Sentinel id for "don't translate" the default selection in the
/// picker. Picked over an `Optional<TranslationLanguage>` 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, 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: "繁體中文"),
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"),
]
/// 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
/// `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 == offLocaleId } ?? all[0]
}
}
@@ -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
)
}
}
@@ -39,6 +39,14 @@ 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 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"
static let polishScenarioId = "config.polishScenarioId"
static let handednessPreference = "config.handednessPreference"
}
// MARK: - Reads
@@ -104,6 +112,33 @@ public struct AppGroupStore: @unchecked Sendable {
AppUILanguage.fromStored(defaults.string(forKey: Key.uiLanguage))
}
/// 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 {
translationTargetLocaleId != TranslationLanguageCatalog.offLocaleId
}
/// v0.2.1: target locale id the translate-and-polish prompt should
/// 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)
?? TranslationLanguageCatalog.offLocaleId
}
public var polishScenarioId: String {
let stored = defaults.string(forKey: Key.polishScenarioId)
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) {
@@ -126,6 +161,97 @@ public struct AppGroupStore: @unchecked Sendable {
defaults.set(language.rawValue, forKey: Key.uiLanguage)
}
/// 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 ? TranslationLanguageCatalog.defaultLocaleId : TranslationLanguageCatalog.offLocaleId,
forKey: Key.translationTargetLocaleId
)
}
/// 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)
AppGroupConfigDarwin.postConfigChanged()
}
public func setPolishScenarioId(_ id: String) {
let resolved = PolishScenarioCatalog.resolve(id).id
defaults.set(resolved, forKey: Key.polishScenarioId)
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).
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
public func makeClient() -> LLMClient {
@@ -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()
@@ -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
@@ -87,6 +87,43 @@ 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 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`.
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// 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
/// 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 {
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.
public var isLocalEngine: Bool { engineMode == "local" }
@@ -101,6 +138,11 @@ 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 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 setPolishScenarioId: (String) -> Void = { _ in }
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
+149 -26
View File
@@ -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,13 +22,20 @@ 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
}
/// v0.2.1: what the LLM should do with the raw transcript. The
/// polish path stays the default so every existing call site keeps
/// its current behaviour translation is opt-in via the `translate`
/// case and gets a target-locale parameter baked into the prompt.
public enum PolishMode: Equatable, Sendable {
case polish
case translate(targetLocaleId: String)
}
private let store: AppGroupStore
private let timeout: TimeInterval
/// Optional injected client (mostly for testing). When nil we build
@@ -52,29 +57,81 @@ public actor PolishingService {
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
}
public func polish(_ raw: String) 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 }
// 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
}
return try await polishRemote(trimmed)
guard store.shouldRunCloudLLMStep else { return trimmed }
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
}
return try await polishRemote(trimmed)
return try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride
)
}
private func polishRemote(_ trimmed: String) async throws -> String {
let client = injectedClient ?? store.makeClient()
let prompt = store.systemPrompt
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/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 {
client = injectedClient
} else {
let preset = LLMProvider.provider(id: effectiveProviderId)
let (baseURL, model) = Self.resolveLLMEndpoint(
store: store,
preset: preset,
providerIdOverride: providerIdOverride
)
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,
providerId: effectiveProviderId
)
let budget = effectiveTimeout(for: trimmed)
return try await withThrowingTaskGroup(of: String.self) { group in
@@ -91,9 +148,75 @@ 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. 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,
providerId: String? = nil
) -> String {
if let override, !override.isEmpty {
return override
}
switch mode {
case .polish:
return store.resolvedPolishSystemPrompt(providerId: providerId)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let pid = providerId ?? store.providerId
return TranslationPrompt.make(
target: target,
providerId: pid,
scenarioId: store.polishScenarioId,
uiLanguage: store.uiLanguage
)
}
}
/// 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
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)."
}
}
}
@@ -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 = "REMOVED_LEAKED_DEEPSEEK_KEY"
#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
}
@@ -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 件事:可用 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: 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.
"""
}
}
}
@@ -0,0 +1,77 @@
// 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.
// Scenario output format (`ScenarioStyleDirective`) is appended so
// translate-and-polish honours the user's polish scenario choice.
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.
/// - 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)
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, directive: String) -> String {
"""
你是一位语音输入翻译与润色助手。用户用 ASR 转写了一段可能含噪声的口述:
1) 先识别原话的主要语言(若不确定则按用户给定的方向处理);
2) 将内容翻译为「\(target.promptLanguageName)」,保留原意,不增删事实、不臆测;
3) 顺带修复 ASR 噪声(同音错字、漏字、断句错乱),让译文读起来自然;
4) 简洁;若下方场景未要求列表/分段,不超过原文 1.5 倍;去掉无意义的口头禅(嗯、啊、那个);
5) 若场景格式要求列表或分段,允许按格式组织译文;总长度不超过原长 2 倍;
6) 只输出译文正文,不要解释、不要加引号、不要前缀""
\(directive)
"""
}
// MARK: - English prompt (for OpenAI / OpenAI-compatible non-Chinese)
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) 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
+12 -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.1"
CURRENT_PROJECT_VERSION: "6"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -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