Merge pull request #3 from hkgood/feature/translation-polish-2
Feature/translation polish 2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,12 +27,27 @@ 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)
|
||||
}
|
||||
.padding(.horizontal, Spacing.md)
|
||||
.padding(.vertical, Spacing.sm)
|
||||
.frame(minHeight: SettingsListMetrics.doubleLineMinHeight)
|
||||
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
||||
}
|
||||
|
||||
// MARK: Helpers
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
HandednessPickerRow(
|
||||
selection: Binding(
|
||||
get: { config.handednessPreference },
|
||||
set: { config.handednessPreference = $0 }
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 — 始终端侧";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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 {
|
||||
guard store.shouldRunCloudLLMStep else { return trimmed }
|
||||
return try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
}
|
||||
|
||||
return try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return try await polishRemote(trimmed)
|
||||
apiKey = preconfigured
|
||||
} else {
|
||||
apiKey = store.apiKey
|
||||
}
|
||||
|
||||
return try await polishRemote(trimmed)
|
||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
||||
}
|
||||
|
||||
private func polishRemote(_ trimmed: String) async throws -> String {
|
||||
let client = injectedClient ?? store.makeClient()
|
||||
let prompt = store.systemPrompt
|
||||
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: 1–2 short sentences; optional greeting + body.
|
||||
- Clear action per item; polite but not overly formal.
|
||||
Allowed: list layout instead of forcing a single dense paragraph.
|
||||
Forbidden: cramming multiple points into one long sentence or prose block.
|
||||
"""
|
||||
case "todo":
|
||||
return """
|
||||
Scenario: TODO / checklist note.
|
||||
Format (required):
|
||||
- Output MUST be markdown "- " bullets, one item per line.
|
||||
- Each line starts with a verb; one task per line; no greeting, no explanation.
|
||||
Forbidden: prose paragraphs, filler, background context.
|
||||
"""
|
||||
case "social_lifestyle":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
Scenario: social network lifestyle post.
|
||||
Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
|
||||
"""
|
||||
}
|
||||
return """
|
||||
Scenario: Xiaohongshu-style lifestyle share.
|
||||
Format: if ≥2 sentences, separate paragraphs with blank lines; first person; light emoji ok; no ad-speak; do not invent experiences.
|
||||
"""
|
||||
case "social_short":
|
||||
if englishPlatformNames {
|
||||
return """
|
||||
Scenario: short Instagram caption.
|
||||
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
|
||||
"""
|
||||
}
|
||||
return """
|
||||
Scenario: Weibo-style short post.
|
||||
Format: concise, punchy opening; high density; keep brief; no invented hashtags or trends.
|
||||
"""
|
||||
case "goofy":
|
||||
return """
|
||||
Scenario: playful chat (goofy tone).
|
||||
Format: natural short sentences; slightly witty wording only.
|
||||
Forbidden: new facts, invented jokes, forced humor on serious topics (leave/apology/complaint).
|
||||
"""
|
||||
case "document":
|
||||
return """
|
||||
Scenario: document / long-form notes.
|
||||
Format: complete sentences; blank lines between topics when ≥2 themes; use "- " bullets for steps/enumerations; `##` headings only when content is long enough; minimal slang.
|
||||
"""
|
||||
case "daily_chat", PolishScenarioCatalog.customId:
|
||||
fallthrough
|
||||
default:
|
||||
return """
|
||||
Scenario: everyday chat (IM/DM).
|
||||
Format: natural short sentences like texting; relaxed punctuation; very light colloquial tone ok.
|
||||
Forbidden: memo/report tone; forced bullets unless the speaker is enumerating.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
"""
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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" = "自定义";
|
||||
|
||||
@@ -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
@@ -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.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 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
|
||||
|
||||
Reference in New Issue
Block a user