feat: cursor navigation, key sounds, dictionary tooling, key security

Batch of in-progress app work from the working tree.

- feat(keyboard): CursorNavigation + CursorDragPad for caret movement;
  KeyboardSoundFeedback for system key click sounds
- feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet;
  TranscriptPostProcessor quality gate; retire DictionaryLearner
- feat(ui): TabBarVisibility handling; drop PageHeaderRow /
  PageHeaderConfirmButton; refresh views and localizable strings
- fix(security): move the hardcoded DeepSeek key out of
  PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift
  (seeded from .example by generate-xcodeproj.sh)
- docs(agents): add Conventional Commits versioning + bilingual changelog rules
- chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache

Custom language model / lexicon work stays on
feature/custom-language-model-asr. Changelog bullets added under
[Unreleased]; no version bump.
This commit is contained in:
Rocky
2026-07-05 18:27:23 +08:00
parent 074e24d87f
commit 05e005e9ce
60 changed files with 3247 additions and 1388 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ import SwiftUI
/// `DesignSystem/ThemedRoot.swift`. Token names mirror the previous
/// `Palette` static API so existing call sites (`Palette.background` etc.)
/// still compile and resolve through the legacy static accessors below.
public struct ThemePalette: Sendable {
public struct ThemePalette: Sendable, Equatable {
public let background: Color
public let surface: Color
public let surfaceElevated: Color
+1 -1
View File
@@ -46,7 +46,7 @@ public enum AppContext: String, Codable, Sendable, CaseIterable {
case .email:
return "Email context: you may add a polite greeting or sign-off if the user clearly forgot one. Reasonable paragraph breaks. Keep tone professional but not stiff."
case .chat:
return "Chat context: keep it short, conversational, and emoji-friendly. Drop formalities. Preserve the speaker's casual voice."
return "Chat context: keep it short, conversational, and natural. Drop formalities. Preserve the speaker's casual voice. Do not add emojis."
case .document:
return "Document context: add structure — split into paragraphs, use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
case .unknown:
+9 -5
View File
@@ -52,13 +52,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
id: "deepseek",
name: "DeepSeek",
defaultBaseURL: "https://api.deepseek.com/v1",
// v0.2.0: bumped default to `deepseek-v4-flash` for the
// local-mode cloud-polish toggle. `deepseek-chat` is
// retained as a valid user-overridable model name; only
// the default is updated.
defaultModel: "deepseek-v4-flash",
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
blurb: "deepseek-v4-flash · 默认 · 快速且中文友好"
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in",
// Local engine only never shown in cloud-engine pickers.
isUserSelectable: false
),
.init(
id: "qwen",
@@ -96,4 +94,10 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
public static func provider(id: String) -> LLMProvider {
presets.first(where: { $0.id == id }) ?? .presets[0]
}
/// Presets the user may pick in Settings / onboarding. DeepSeek is
/// excluded it is wired exclusively to the local engine.
public static var userSelectablePresets: [LLMProvider] {
presets.filter(\.isUserSelectable)
}
}
@@ -7,8 +7,7 @@
//
// Sources (mutually exclusive per entry):
// - `.manual` user typed it in by hand
// - `.history` auto-extracted from the user's transcription
// history by `DictionaryLearner`
// - `.history` legacy auto-learned entries (migrated to `.manual`)
// - `.contacts` imported from the iOS Contacts framework
// - `.recentEdit` extracted from edits the user made to a
// polished transcript before sending
@@ -104,13 +103,124 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
}
}
extension PersonalDictionary.Entry {
/// Lightweight category inference for manual adds and the history
/// learner. Users can re-classify later from Settings.
public static func inferCategory(for term: String) -> Category {
let hasUpper = term.contains(where: { $0.isUppercase })
let hasDigit = term.contains(where: { $0.isNumber })
let hasLatin = term.unicodeScalars.contains { scalar in
CharacterSet.letters.contains(scalar) && scalar.isASCII
}
if hasUpper, !term.contains(where: { $0.isLowercase }) {
return .acronym
}
if hasDigit {
return .productName
}
if !hasLatin {
return .properNoun
}
return .productName
}
}
extension PersonalDictionary {
public static let empty = PersonalDictionary()
/// Built-in terms always included in LLM prompts. Never persisted
/// and never shown in the Settings personal-dictionary UI.
public static let systemEntries: [Entry] = [
Entry(
id: UUID(uuidString: "A0000000-0000-4000-8000-000000000001")!,
term: "OSGKeyboard",
aliases: [],
category: .productName,
source: .manual,
createdAt: Date(timeIntervalSince1970: 0),
usageCount: 0
),
]
/// User entries plus built-in system terms (deduped by term).
public var effectiveEntries: [Entry] {
var merged = Self.systemEntries
let systemTerms = Set(Self.systemEntries.map { $0.term.lowercased() })
for entry in entries where !systemTerms.contains(entry.term.lowercased()) {
merged.append(entry)
}
return merged
}
/// Case-insensitive lookup by canonical term.
public func entry(matchingTerm term: String) -> Entry? {
let key = term.lowercased()
return entries.first { $0.term.lowercased() == key }
}
/// Insert or update a manual entry. Returns the saved entry.
@discardableResult
public mutating func upsertManual(
term: String,
existingID: UUID? = nil,
regenerateAliases: Bool = false
) -> Entry? {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let category = Entry.inferCategory(for: trimmed)
if let existingID,
let idx = entries.firstIndex(where: { $0.id == existingID }) {
var entry = entries[idx]
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
entry.term = trimmed
entry.category = category
entry.source = .manual
if termChanged || regenerateAliases {
entry.aliases = []
}
entries[idx] = entry
return entry
}
if let idx = entries.firstIndex(where: {
$0.term.caseInsensitiveCompare(trimmed) == .orderedSame
}) {
var entry = entries[idx]
entry.term = trimmed
entry.category = category
entry.source = .manual
entries[idx] = entry
return entry
}
let entry = Entry(
term: trimmed,
aliases: [],
category: category,
source: .manual
)
entries.append(entry)
return entry
}
public mutating func updateAliases(for entryID: UUID, aliases: [String]) {
guard let idx = entries.firstIndex(where: { $0.id == entryID }) else { return }
let cleaned = aliases
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let termLower = entries[idx].term.lowercased()
entries[idx].aliases = Array(
Set(cleaned.filter { $0.lowercased() != termLower })
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
/// Renders the entire dictionary as a prompt fragment. Entries
/// are grouped by category so the LLM can scan quickly. Empty
/// dictionary returns "" so the caller can blindly concatenate.
public func promptFragment() -> String {
let entries = effectiveEntries
guard !entries.isEmpty else { return "" }
let grouped = Dictionary(grouping: entries, by: { $0.category })
var lines: [String] = []
+29 -14
View File
@@ -9,14 +9,9 @@
import Foundation
public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// Engine is in pure ASR mode (local + cloud-polish-off). The LLM
/// is never called; the raw transcript is inserted as-is. This
/// value is mostly a UI default the actual behaviour is
/// determined by `engineMode` + `localModeCloudPolishEnabled`.
case off
/// Drop only isolated filler words ( / / / / )
/// and obvious duplicated fragments. Everything else stays.
/// and obvious duplicated fragments. Punctuation and structure
/// formatting still apply at every intensity level.
case light
/// Correction + light polish: drop fillers, fix homophone errors,
@@ -34,7 +29,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// and the keyboard extension.
public var labelKey: String {
switch self {
case .off: return "polish.intensity.off"
case .light: return "polish.intensity.light"
case .medium: return "polish.intensity.medium"
case .heavy: return "polish.intensity.heavy"
@@ -45,7 +39,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// story as `labelKey`.
public var descriptionKey: String {
switch self {
case .off: return "polish.intensity.off.desc"
case .light: return "polish.intensity.light.desc"
case .medium: return "polish.intensity.medium.desc"
case .heavy: return "polish.intensity.heavy.desc"
@@ -57,16 +50,38 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// non-ambiguous constraint per call.
public var promptGuideline: String {
switch self {
case .off:
return "Do not change the input at all. Output the original text verbatim."
case .light:
return "Only remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok) and obvious duplicated fragments. Do not change any other words, word order, or punctuation."
return """
Light rewrite: remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok, um, uh) and obvious duplicated fragments only. \
Do not rephrase otherwise-clear wording. \
Still restore punctuation, sentence breaks, and content-triggered structure (lists, paragraphs) per the global output contract.
"""
case .medium:
return "Correct obvious speech-recognition errors (homophones, missing/extra characters). Remove filler words and duplicated fragments. Adjust obviously-broken word order. Add punctuation. Do not restructure sentences, invent facts, or change the speaker's voice."
return """
Medium rewrite: fix obvious ASR errors (homophones, missing/extra characters), remove fillers and duplicated fragments, \
adjust obviously-broken word order. Preserve the speaker's voice. \
Still restore punctuation, sentence breaks, and content-triggered structure per the global output contract. \
Do not invent facts or change numbers/proper nouns.
"""
case .heavy:
return "Apply medium corrections, then optionally restructure: split long sentences, auto-number enumerated items into markdown lists, group related ideas into paragraphs. Preserve every fact, number, and proper noun."
return """
Heavy rewrite: apply medium corrections, then you may reorganize paragraphs, split long sentences, and listify enumerated content. \
Punctuation and structure are mandatory at every intensity. \
Preserve every fact, number, and proper noun. Do not add information.
"""
}
}
/// Legacy persisted value `"off"` maps to `.medium` on read.
public static func resolve(storedRawValue raw: String) -> PolishIntensity {
if raw == legacyOffRawValue {
return .medium
}
return PolishIntensity(rawValue: raw) ?? .default
}
/// Raw value written by builds before the off tier was removed.
public static let legacyOffRawValue = "off"
}
extension PolishIntensity {
+71 -54
View File
@@ -51,12 +51,21 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// "on" state during init, but new writes never touch the key.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let handednessPreference = "config.handednessPreference"
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
// v0.3.0: how aggressively the LLM should rewrite transcripts.
static let polishIntensity = "config.polishIntensity"
}
@Published public var providerId: String {
didSet { defaults.set(providerId, forKey: Key.providerId) }
didSet {
defaults.set(providerId, forKey: Key.providerId)
// Keep API keys isolated per provider: switching provider in
// Settings loads that provider's key instead of reusing the
// previously selected vendor's key.
isSyncingProviderAPIKey = true
apiKey = Keychain.apiKey(for: providerId) ?? ""
isSyncingProviderAPIKey = false
}
}
@Published public var baseURL: String {
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
@@ -65,9 +74,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet {
// Skip the round-trip on init we read from Keychain and
// writing the same value back is wasteful.
guard oldValue != apiKey else { return }
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
do {
try Keychain.setAPIKey(apiKey)
try Keychain.setAPIKey(apiKey, for: providerId)
} catch {
#if DEBUG
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
@@ -84,10 +93,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
@Published public var localeId: String {
didSet { defaults.set(localeId, forKey: Key.localeId) }
}
/// "local" on-device ASR only (raw transcript delivery).
/// "cloud" ASR + LLM polish (always on; modeId kept for compatibility).
/// "local" on-device ASR + built-in DeepSeek polish.
/// "cloud" on-device ASR + user's cloud LLM polish.
@Published public var engineMode: String {
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
didSet {
defaults.set(engineMode, forKey: Key.engineMode)
applyEngineModeSideEffects()
}
}
@Published public var hasCompletedOnboarding: Bool {
didSet {
@@ -162,27 +174,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// 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
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool {
didSet {
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
AppGroupConfigDarwin.postConfigChanged()
}
}
/// 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
/// Whether the pipeline should run translate-and-polish (not just
/// polish). Both engines honour the selected target locale.
public var isTranslationEffective: Bool {
translationEnabled
}
/// Translation picker visibility available on both engines.
public var isTranslationRowVisible: Bool { true }
/// v0.3.0: how aggressively the LLM should rewrite the ASR
/// transcript. Default is `medium` (Typeless-equivalent). The
/// `off` value never calls the LLM equivalent to "transcribe
/// only" regardless of `engineMode`.
/// transcript. Default is `medium` (Typeless-equivalent).
@Published public var polishIntensity: PolishIntensity {
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
}
@@ -200,32 +210,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// On-device ASR only; no cloud API required.
public var isLocalEngine: Bool { engineMode == "local" }
/// Whether a transcript produced by the local engine should be
/// sent through the cloud LLM polish step before insertion.
///
/// v0.2.0: the local engine defaults to ASR-only. When the user
/// enables "Cloud polish after ASR" (`localModeCloudPolishEnabled`)
/// we route the transcript through the configured LLM (DeepSeek by
/// default in local mode) same `PolishingService` code path the
/// cloud engine uses.
///
/// If the user hasn't entered an API key we can't run the polish
/// step; callers should check `Keychain.apiKey()` before invoking.
public var shouldPolishLocalTranscript: Bool {
isLocalEngine && localModeCloudPolishEnabled
}
/// Local engine always polishes via the built-in DeepSeek path.
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
/// 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
}
/// Cloud engine uses `providerId`. Local engine pins DeepSeek.
public var localModeProviderId: String { "deepseek" }
private let defaults: UserDefaults
private var isSyncingProviderAPIKey = false
public init(defaults: UserDefaults? = nil) {
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
@@ -239,7 +231,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
// is empty in the suite and all subsequent reads go through the
// Keychain.
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults)
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
@@ -278,12 +270,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.handednessPreference = HandednessPreference.fromStored(
resolvedDefaults.string(forKey: Key.handednessPreference)
)
if resolvedDefaults.object(forKey: Key.cursorDragNavigationEnabled) == nil {
self.cursorDragNavigationEnabled = true
} else {
self.cursorDragNavigationEnabled = resolvedDefaults.bool(forKey: Key.cursorDragNavigationEnabled)
}
// v0.3.0: polish intensity. Default to `.medium` for new
// installs and upgrades; the existing `off` / `light` /
// `heavy` values are honored.
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity),
let intensity = PolishIntensity(rawValue: raw) {
self.polishIntensity = intensity
// installs; legacy `"off"` migrates to `.medium`.
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity) {
self.polishIntensity = PolishIntensity.resolve(storedRawValue: raw)
if raw == PolishIntensity.legacyOffRawValue {
resolvedDefaults.set(PolishIntensity.medium.rawValue, forKey: Key.polishIntensity)
}
} else {
self.polishIntensity = .default
}
@@ -292,17 +290,36 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
if self.engineMode == "cloud", self.modeId != "polish" {
self.modeId = "polish"
}
// DeepSeek is local-engine only never a cloud picker choice.
if self.engineMode == "cloud", self.providerId == "deepseek" {
apply(preset: LLMProvider.provider(id: "openai"))
}
}
/// Keep cloud vs local provider choices isolated when the user
/// switches engines in Settings / onboarding.
private func applyEngineModeSideEffects() {
if engineMode == "cloud", providerId == "deepseek" {
apply(preset: LLMProvider.provider(id: "openai"))
}
}
/// Read the API key from the Keychain, falling back to a one-time
/// migration from the legacy UserDefaults slot.
private static func resolveAPIKey(defaults: UserDefaults) -> String {
if let stored = Keychain.apiKey(), !stored.isEmpty {
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
return stored
}
// Migration path: old builds stored one global key under
// Keychain account "current". Move it to the active provider.
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
try? Keychain.deleteLegacyAPIKey()
return legacyKeychain
}
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
!legacy.isEmpty {
try? Keychain.setAPIKey(legacy)
try? Keychain.setAPIKey(legacy, for: providerId)
defaults.removeObject(forKey: Key.apiKeyLegacy)
return legacy
}
+48 -22
View File
@@ -45,6 +45,8 @@ public struct AppGroupStore: @unchecked Sendable {
// computed shim for source compatibility.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let handednessPreference = "config.handednessPreference"
// Drag pads beside the mic move the caret like arrow keys.
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
// v0.3.0: polish intensity (off / light / medium / heavy).
static let polishIntensity = "config.polishIntensity"
// v0.3.0: last app context detected by the keyboard extension.
@@ -70,7 +72,7 @@ public struct AppGroupStore: @unchecked Sendable {
/// Returns "" when nothing is stored so the LLMClient can surface a
/// `noAPIKey` error rather than firing off an obviously-bad request.
public var apiKey: String {
Keychain.apiKey() ?? ""
Keychain.apiKey(for: providerId) ?? ""
}
public var model: String {
@@ -137,6 +139,15 @@ public struct AppGroupStore: @unchecked Sendable {
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
}
/// Press-and-drag pads beside the mic for four-way caret movement.
/// Defaults to `true` for new installs.
public var cursorDragNavigationEnabled: Bool {
guard defaults.object(forKey: Key.cursorDragNavigationEnabled) != nil else {
return true
}
return defaults.bool(forKey: Key.cursorDragNavigationEnabled)
}
// MARK: - Writes
public func setModeId(_ id: String) {
@@ -187,29 +198,30 @@ public struct AppGroupStore: @unchecked Sendable {
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
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.cursorDragNavigationEnabled)
AppGroupConfigDarwin.postConfigChanged()
}
/// Whether ASR output should be sent through the LLM polish step.
/// Both engines always run polish after ASR completes (chunked
/// pipeline stitches first). Ultra-short structure-free utterances
/// may skip the LLM inside `PolishingService`.
public var shouldRunCloudLLMStep: Bool { true }
/// 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
translationEnabled
}
/// 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
public var isTranslationChipVisible: Bool { true }
/// Cloud engine requires a provider-specific API key before the user
/// can start voice input. Local engine uses the built-in DeepSeek path.
public var isCloudAPIKeyMissingForVoiceInput: Bool {
guard engineMode == "cloud" else { return false }
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Polish vs translate-and-polish for the active pipeline.
@@ -230,10 +242,14 @@ public struct AppGroupStore: @unchecked Sendable {
/// How aggressively the LLM should rewrite the ASR transcript.
/// Defaults to `medium` for new installs.
public var polishIntensity: PolishIntensity {
guard let raw = defaults.string(forKey: Key.polishIntensity),
let value = PolishIntensity(rawValue: raw)
else { return .default }
return value
guard let raw = defaults.string(forKey: Key.polishIntensity) else {
return .default
}
let resolved = PolishIntensity.resolve(storedRawValue: raw)
if raw == PolishIntensity.legacyOffRawValue {
defaults.set(resolved.rawValue, forKey: Key.polishIntensity)
}
return resolved
}
public func setPolishIntensity(_ intensity: PolishIntensity) {
@@ -296,7 +312,17 @@ public struct AppGroupStore: @unchecked Sendable {
return .empty
}
do {
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
if dictionary.entries.contains(where: { $0.source == .history }) {
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
dictionary.entries[index].source = .manual
}
dictionary.version += 1
if let migrated = try? JSONEncoder().encode(dictionary) {
defaults.set(migrated, forKey: Key.personalDictionary)
}
}
return dictionary
} catch {
#if DEBUG
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
@@ -0,0 +1,328 @@
// CursorNavigation.swift
// OSGKeyboard · Shared
//
// Pure helpers for moving the text caret from the keyboard extension.
// Horizontal moves are character-accurate. Vertical moves jump between
// *visual* lines hard `\n` breaks and soft wraps.
//
// First-principles note: a keyboard extension only sees a bounded text
// window (`documentContext{Before,After}Input`) and can only actuate via
// `adjustTextPosition(byCharacterOffset:)`. It has NO access to the host
// field's font, width, or caret rect, so soft-wrap positions are
// fundamentally unknowable and must be *estimated*. We reduce the visible
// error two ways: (1) exact handling of hard `\n`; (2) an injectable
// per-character width so the extension can feed real font metrics (killing
// the i-vs-W column drift that a fixed 1/2 table causes). The wrap width
// itself stays a calibrated estimate.
import Foundation
import CoreGraphics
public enum CursorNavigation {
/// Advance width of a single character, in an arbitrary but consistent
/// unit (points when backed by real font metrics; abstract "units" for
/// the built-in default). Must be paired with a `lineWidth` in the same
/// unit.
public typealias CharacterWidth = @Sendable (Character) -> CGFloat
// MARK: - Layout config
/// Describes how text wraps into visual lines. `lineWidth` and the values
/// returned by `widthOf` must share the same unit.
public struct VisualLineLayoutConfig: Sendable {
/// Wrap threshold: max total width of one visual line.
public let lineWidth: CGFloat
/// Per-character advance width provider.
public let widthOf: CharacterWidth
public init(
lineWidth: CGFloat,
widthOf: @escaping CharacterWidth = CursorNavigation.defaultDisplayWidth
) {
self.lineWidth = max(1, lineWidth)
self.widthOf = widthOf
}
/// Conservative default when no field width is known.
public static let fallback = VisualLineLayoutConfig(lineWidth: 44)
}
// MARK: - Public API
/// Legacy logical column (chars since last `\n`). Kept for tests.
public static func column(before: String?) -> Int {
guard let before, !before.isEmpty else { return 0 }
if let lastNewline = before.lastIndex(of: "\n") {
return before.distance(from: before.index(after: lastNewline), to: before.endIndex)
}
return before.count
}
/// Display-column offset (in `widthOf` units) on the current visual line.
public static func visualDisplayColumn(
before: String?,
after: String?,
config: VisualLineLayoutConfig
) -> CGFloat {
let text = mergedContext(before: before, after: after)
let cursor = before?.count ?? 0
let layout = VisualLineLayout(text: text, config: config)
let lineStart = layout.lineStart(containing: cursor)
return layout.width(from: lineStart, to: cursor)
}
/// One visual line up. Returns caret offset and the display column to
/// keep sticky for the rest of this vertical drag.
public static func visualLineUpOffset(
before: String?,
after: String?,
preferredDisplayColumn: CGFloat?,
config: VisualLineLayoutConfig
) -> (offset: Int, stickyColumn: CGFloat)? {
let text = mergedContext(before: before, after: after)
let cursor = before?.count ?? 0
let layout = VisualLineLayout(text: text, config: config)
guard let currentLine = layout.lineIndex(containing: cursor), currentLine > 0 else {
return nil
}
let sticky = preferredDisplayColumn
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
let previousStart = layout.lineStarts[currentLine - 1]
let previousEnd = layout.lineStarts[currentLine]
let target = layout.offset(
onLineStartingAt: previousStart,
lineEndingBefore: previousEnd,
displayColumn: sticky
)
let offset = target - cursor
guard offset != 0 else { return nil }
return (offset, sticky)
}
/// One visual line down.
public static func visualLineDownOffset(
before: String?,
after: String?,
preferredDisplayColumn: CGFloat?,
config: VisualLineLayoutConfig
) -> (offset: Int, stickyColumn: CGFloat)? {
let text = mergedContext(before: before, after: after)
let cursor = before?.count ?? 0
let layout = VisualLineLayout(text: text, config: config)
guard let currentLine = layout.lineIndex(containing: cursor) else { return nil }
guard currentLine + 1 < layout.lineStarts.count else { return nil }
let sticky = preferredDisplayColumn
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
let nextStart = layout.lineStarts[currentLine + 1]
let nextEnd = currentLine + 2 < layout.lineStarts.count
? layout.lineStarts[currentLine + 2]
: text.count
let target = layout.offset(
onLineStartingAt: nextStart,
lineEndingBefore: nextEnd,
displayColumn: sticky
)
let offset = target - cursor
guard offset != 0 else { return nil }
return (offset, sticky)
}
// MARK: - Default width table
/// Crude fallback advance width: wide scripts count double, everything
/// else single. Used by tests and when real metrics are unavailable.
public static func defaultDisplayWidth(_ character: Character) -> CGFloat {
guard let scalar = character.unicodeScalars.first else { return 1 }
if character == "\n" { return 0 }
if character == "\t" { return 4 }
if isWide(scalar) { return 2 }
return 1
}
private static func isWide(_ scalar: UnicodeScalar) -> Bool {
let value = scalar.value
return (0x1100...0x115F).contains(value) // Hangul Jamo
|| (0x2E80...0xA4CF).contains(value) // CJK radicals, symbols, bopomofo, yi
|| (0xAC00...0xD7A3).contains(value) // Hangul syllables
|| (0xF900...0xFAFF).contains(value) // CJK compatibility
|| (0xFE10...0xFE1F).contains(value) // vertical forms
|| (0xFE30...0xFE6F).contains(value) // CJK compatibility forms
|| (0xFF00...0xFF60).contains(value) // fullwidth
|| (0xFFE0...0xFFE6).contains(value) // fullwidth symbols
|| (0x20000...0x2FFFF).contains(value) // CJK extension planes
|| (0x30000...0x3FFFF).contains(value)
}
// MARK: - Internals
private static func mergedContext(before: String?, after: String?) -> String {
(before ?? "") + (after ?? "")
}
// MARK: - Visual line layout
struct VisualLineLayout {
let text: String
let widthOf: CharacterWidth
let lineStarts: [Int]
init(text: String, config: VisualLineLayoutConfig) {
self.text = text
self.widthOf = config.widthOf
self.lineStarts = Self.computeLineStarts(
in: text,
maxWidth: config.lineWidth,
widthOf: config.widthOf
)
}
func lineIndex(containing offset: Int) -> Int? {
guard !lineStarts.isEmpty else { return nil }
for index in lineStarts.indices.reversed() where offset >= lineStarts[index] {
return index
}
return nil
}
func lineStart(containing offset: Int) -> Int {
lineIndex(containing: offset).map { lineStarts[$0] } ?? 0
}
func width(from start: Int, to end: Int) -> CGFloat {
guard start < end, end <= text.count else { return 0 }
let startIndex = text.index(text.startIndex, offsetBy: start)
let endIndex = text.index(text.startIndex, offsetBy: end)
var total: CGFloat = 0
var index = startIndex
while index < endIndex {
total += widthOf(text[index])
index = text.index(after: index)
}
return total
}
func offset(
onLineStartingAt lineStart: Int,
lineEndingBefore lineEnd: Int,
displayColumn: CGFloat
) -> Int {
guard lineStart <= lineEnd, lineEnd <= text.count else { return lineStart }
let startIndex = text.index(text.startIndex, offsetBy: lineStart)
let endIndex = text.index(text.startIndex, offsetBy: lineEnd)
var total: CGFloat = 0
var index = startIndex
while index < endIndex {
let advance = widthOf(text[index])
if total + advance > displayColumn { break }
total += advance
index = text.index(after: index)
}
return text.distance(from: text.startIndex, to: index)
}
private static func computeLineStarts(
in text: String,
maxWidth: CGFloat,
widthOf: CharacterWidth
) -> [Int] {
guard !text.isEmpty else { return [0] }
var starts: [Int] = [0]
var lineWidth: CGFloat = 0
var lineStart = text.startIndex
var lastBreak: String.Index?
var index = text.startIndex
while index < text.endIndex {
let character = text[index]
if character == "\n" {
let next = text.index(after: index)
let nextOffset = text.distance(from: text.startIndex, to: next)
if starts.last != nextOffset {
starts.append(nextOffset)
}
lineStart = next
lineWidth = 0
lastBreak = nil
index = next
continue
}
let advance = widthOf(character)
if character == " " || character == "\t" {
lastBreak = index
}
if lineWidth + advance > maxWidth, index > lineStart {
let breakIndex: String.Index
if let lastBreak, lastBreak > lineStart {
breakIndex = text.index(after: lastBreak)
} else {
breakIndex = index
}
let breakOffset = text.distance(from: text.startIndex, to: breakIndex)
if starts.last != breakOffset {
starts.append(breakOffset)
}
lineStart = breakIndex
lineWidth = 0
lastBreak = nil
if breakIndex == index {
lineWidth = advance
index = text.index(after: index)
}
continue
}
lineWidth += advance
index = text.index(after: index)
}
return starts
}
}
}
#if canImport(UIKit)
import UIKit
/// Real-font per-character advance widths (in points) for cursor visual-line
/// navigation. Caches measurements so repeated drag samples are cheap.
///
/// Absolute values assume a ~17 pt body font; only the *ratios* between
/// glyphs (and between a glyph and the field width) matter for column
/// fidelity, so a reference font is sufficient to eliminate the fixed-width
/// column drift.
///
/// Not actor-isolated on purpose: the width closure is invoked synchronously
/// from the nonisolated `CursorNavigation` layout code. A lock guards the
/// cache so `@unchecked Sendable` is safe.
public final class CursorGlyphMetrics: @unchecked Sendable {
public static let shared = CursorGlyphMetrics()
private let font = UIFont.systemFont(ofSize: 17)
private let lock = NSLock()
private var cache: [Character: CGFloat] = [:]
public init() {}
public func width(of character: Character) -> CGFloat {
if character == "\n" { return 0 }
lock.lock()
defer { lock.unlock() }
if let cached = cache[character] { return cached }
let measured = (String(character) as NSString)
.size(withAttributes: [.font: font])
.width
let width = measured > 0 ? measured : font.pointSize * 0.5
cache[character] = width
return width
}
}
#endif
+21 -12
View File
@@ -71,6 +71,11 @@ public final class KeyboardState: ObservableObject {
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
/// Whether the host app's Flow voice session is currently valid.
@Published public var flowSessionActive: Bool = false
/// When true, the mic is intentionally disabled (e.g. cloud engine
/// selected but the provider-specific API key is missing).
@Published public var micDisabled: Bool = false
/// One-line helper shown above the mic while `micDisabled == true`.
@Published public var micDisabledHint: String = ""
/// "local" on-device ASR only. "cloud" ASR + LLM polish.
@Published public var engineMode: String = "cloud"
/// Which on-device ASR engine to use when `engineMode == "local"`.
@@ -98,24 +103,23 @@ public final class KeyboardState: ObservableObject {
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// v0.2.0: mirrored from App Group local engine runs the cloud
/// LLM step only when this is `true`.
@Published public var localModeCloudPolishEnabled: Bool = false
/// v0.2.0: mirrored from App Group kept for source compatibility.
/// Local engine always runs built-in polish; the flag is ignored.
@Published public var localModeCloudPolishEnabled: Bool = true
/// 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).
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool = true
/// `true` while a cursor-drag pad is being pressed drives the hint
/// shown above the mic.
@Published public var cursorDragActive: Bool = false
/// Whether translate-and-polish is armed for the current engine.
public var isTranslationEffective: Bool {
guard translationEnabled else { return false }
if isLocalEngine { return localModeCloudPolishEnabled }
return true
translationEnabled
}
/// Whether the keyboard top-bar translation chip should render.
public var isTranslationChipVisible: Bool {
if isLocalEngine { return localModeCloudPolishEnabled }
return true
}
public var isTranslationChipVisible: Bool { true }
/// Convenience shorthand used by the pipeline and views.
public var isLocalEngine: Bool { engineMode == "local" }
@@ -168,6 +172,11 @@ public final class KeyboardState: ObservableObject {
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
public var moveCursorHorizontal: (Int) -> Void = { _ in }
public var moveCursorVertical: (Int) -> Void = { _ in }
/// Cursor-drag pad press lifecycle updates `cursorDragActive` and
/// lets the view controller reset vertical-navigation stickiness.
public var setCursorDragActive: (Bool) -> Void = { _ in }
// MARK: - Preview helpers (DEBUG only)
+61 -8
View File
@@ -39,18 +39,25 @@ public enum Keychain: @unchecked Sendable {
}
private static let service = "com.osgkeyboard.apikey"
private static let account = "current"
private static let legacyAccount = "current"
private static let defaultProviderId = "openai"
private static func account(for providerId: String) -> String {
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
return "provider.\(normalized)"
}
// MARK: - Read
/// Read the stored API key. Returns `nil` when nothing is stored,
/// or when the underlying call returns a non-success status we can't
/// usefully surface (e.g. transient `errSecInteractionNotAllowed`).
public static func apiKey() -> String? {
public static func apiKey(for providerId: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccount as String: account(for: providerId),
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
@@ -73,21 +80,45 @@ public enum Keychain: @unchecked Sendable {
}
}
/// Backward-compatible shorthand for the default cloud provider.
public static func apiKey() -> String? {
apiKey(for: defaultProviderId)
}
/// Legacy account used by older builds before provider-scoped keys.
/// New code should avoid this and use `apiKey(for:)`.
public static func legacyAPIKey() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
// MARK: - Write
/// Store (or update) the API key. An empty string deletes the entry,
/// so clearing the field in the UI removes the key from the Keychain
/// rather than leaving an empty-string placeholder.
public static func setAPIKey(_ key: String) throws {
public static func setAPIKey(_ key: String, for providerId: String) throws {
if key.isEmpty {
try deleteAPIKey()
try deleteAPIKey(for: providerId)
return
}
let data = Data(key.utf8)
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccount as String: account(for: providerId),
]
// Try update first covers the common path where the key already
// exists (every settings edit after the first).
@@ -112,13 +143,18 @@ public enum Keychain: @unchecked Sendable {
}
}
/// Backward-compatible shorthand for the default cloud provider.
public static func setAPIKey(_ key: String) throws {
try setAPIKey(key, for: defaultProviderId)
}
// MARK: - Delete
public static func deleteAPIKey() throws {
public static func deleteAPIKey(for providerId: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccount as String: account(for: providerId),
]
let status = SecItemDelete(query as CFDictionary)
// `errSecItemNotFound` is success-from-the-user's-perspective the
@@ -127,4 +163,21 @@ public enum Keychain: @unchecked Sendable {
throw KeychainError.unexpectedStatus(status)
}
}
/// Backward-compatible shorthand for the default cloud provider.
public static func deleteAPIKey() throws {
try deleteAPIKey(for: defaultProviderId)
}
public static func deleteLegacyAPIKey() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unexpectedStatus(status)
}
}
}
+19 -7
View File
@@ -36,15 +36,25 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
}
public protocol LLMClient: Sendable {
func polish(_ text: String, systemPrompt: String) async throws -> String
/// Polish `text` with `systemPrompt`. `timeout` overrides the
/// per-request HTTP timeout for this call; when `nil` the client's
/// `requestTimeout` baseline is used. Long transcripts must pass a
/// larger, length-scaled timeout so the HTTP request is not cut off
/// mid-generation (see `PolishingService.effectiveTimeout`).
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String
/// Single source of truth for the upper bound on a single LLM HTTP
/// round-trip. Both the `URLRequest` we send and any wrapping
/// timeout-style race (e.g. `PolishingService`'s `withThrowingTaskGroup`)
/// must read from this property so the two never disagree.
/// Baseline upper bound for a single LLM HTTP round-trip when no
/// per-request `timeout` is supplied.
var requestTimeout: TimeInterval { get }
}
public extension LLMClient {
/// Convenience overload that uses the baseline `requestTimeout`.
func polish(_ text: String, systemPrompt: String) async throws -> String {
try await polish(text, systemPrompt: systemPrompt, timeout: nil)
}
}
// MARK: - OpenAI-compatible implementation
public struct OpenAICompatibleClient: LLMClient {
@@ -71,7 +81,7 @@ public struct OpenAICompatibleClient: LLMClient {
self.session = session
}
public func polish(_ text: String, systemPrompt: String) async throws -> String {
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
let urlString = baseURL.hasSuffix("/")
@@ -93,7 +103,9 @@ public struct OpenAICompatibleClient: LLMClient {
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.timeoutInterval = requestTimeout
// Per-request timeout scales with transcript length; fall back to
// the baseline when the caller does not supply one.
req.timeoutInterval = timeout ?? requestTimeout
let encoder = JSONEncoder()
req.httpBody = try encoder.encode(request)
+151 -131
View File
@@ -10,24 +10,15 @@
// English dictation while halving the network round-trip.
//
// Engine matrix:
// - `engineMode == "cloud"` always polish
// - `engineMode == "local"`,
// cloud polish disabled ASR-only, return raw.
// - `engineMode == "local"`,
// 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.
// - `polishIntensity == .off` ASR-only, return raw,
// regardless of engine mode
// - Missing API key return raw + throw
// `.missingAPIKey` so the caller can show the "fill in your key"
// hint inline
// - `engineMode == "cloud"` on-device ASR, then user's cloud LLM
// - `engineMode == "local"` on-device ASR, then built-in DeepSeek
// - Ultra-short, structure-free utterances skip the LLM entirely
// - Cloud without API key raw + `.missingAPIKey` warning
// - Local without build key raw + `.missingAPIKey` warning
//
// Caller-supplied `PolishContext` carries the per-call signals:
// - `appContext` code / email / chat / document / unknown
// - `intensity` off / light / medium / heavy (per-call
// override; default is the user-configured value)
// - `intensity` light / medium / heavy (per-call override)
// - `precedingText` optional tail of the cursor's preceding text
// for reference resolution
//
@@ -63,11 +54,11 @@ public actor PolishingService {
/// one from `store.makeClient()` per call.
private let injectedClient: LLMClient?
/// Default `timeout` is `LLMClient.requestTimeout + 1` second so the
/// safety-net `withThrowingTaskGroup` never wins the race against
/// the URL request itself; if the request times out cleanly the
/// network error reaches us first. The +1 is the single point of
/// slack between the two clocks keep it here, not in `LLMClient`.
/// `timeout` is the baseline (shortest) per-request HTTP timeout,
/// used as the floor for `effectiveTimeout(for:)`. It defaults to the
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its
/// own slack on top of the length-scaled budget in `polishRemote`, so
/// no `+1` is baked in here.
public init(
store: AppGroupStore = AppGroupStore(),
client: LLMClient? = nil,
@@ -75,10 +66,10 @@ public actor PolishingService {
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
}
/// v0.3.0: context-aware polish entry point. The optional
/// v0.3.0: context-aware polish entry point. The optional
/// `PolishContext` carries per-call signals (app context,
/// intensity, preceding text). Translation is a separate concept
/// (see `mode` below) so callers wanting the v0.2.1 translate
@@ -94,42 +85,38 @@ public actor PolishingService {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
// Resolve per-call context: per-call override wins over the
// user-configured App Group value.
let resolvedContext = resolveContext(override: context)
// "off" intensity never calls the LLM, regardless of engine
// or mode. This lets users opt into "transcribe only" with
// one tap without having to flip the engine mode or pick a
// translation off-locale.
if resolvedContext.intensity == .off {
return trimmed
// Ultra-short, structure-free inputs skip the LLM to save
// latency (e.g. "", "OK", "").
if mode == .polish,
systemPrompt == nil || systemPrompt?.isEmpty == true,
TranscriptPostProcessor.shouldSkipLLM(for: trimmed) {
return TranscriptPostProcessor.localClean(trimmed)
}
// Local engine + cloud-polish-off: pure ASR, no LLM.
if store.engineMode == "local" {
guard store.shouldRunCloudLLMStep else { return trimmed }
} else {
// Cloud engine needs an API key.
if store.engineMode == "cloud", injectedClient == nil {
guard !store.apiKey.isEmpty else {
throw PolishError.missingAPIKey
}
}
return try await polishRemote(
let llmResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: resolvedContext
)
// Translation and custom prompts bypass the polish post-processor.
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
return llmResult
}
return TranscriptPostProcessor.process(original: trimmed, llmOutput: llmResult)
}
/// Build the final `PolishContext` for this call. Per-call
/// overrides take precedence; otherwise we read the user-configured
/// values out of the App Group (so the keyboard extension's
/// `PolishingService` instance does not need to know about
/// `ProviderConfig`).
private func resolveContext(override: PolishContext?) -> PolishContext {
guard let override else {
return PolishContext(
@@ -137,10 +124,6 @@ public actor PolishingService {
intensity: store.polishIntensity
)
}
// If the override leaves a field at its default-when-nil
// value, fall back to the App Group value. Today every
// `PolishContext` field is non-optional so this branch
// simply forwards; kept for future-proofing.
return override
}
@@ -151,12 +134,10 @@ public actor PolishingService {
providerIdOverride: String? = nil,
context: PolishContext
) 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 effectiveProviderId = Self.resolvedProviderId(
store: store,
providerIdOverride: providerIdOverride
)
let client: LLMClient
if let injectedClient {
client = injectedClient
@@ -169,32 +150,27 @@ public actor PolishingService {
)
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.
guard PreconfiguredKeys.isDeepseekConfigured else {
throw PolishError.missingAPIKey
}
apiKey = preconfigured
apiKey = PreconfiguredKeys.deepseek
} else {
apiKey = store.apiKey
}
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
}
// Polish-mode callers (and translation-mode callers that
// haven't supplied an explicit override) get the new
// "intelligent" prompt that uses `PolishContext.appContext`,
// `intensity`, and the personal dictionary. Translation-mode
// callers keep the v0.2.1 `TranslationPrompt` path so the
// translate-and-polish output contract doesn't change.
let prompt: String
if let override = systemPrompt, !override.isEmpty {
prompt = override
} else {
switch mode {
case .polish:
prompt = buildPrompt(for: trimmed, context: context)
prompt = buildPrompt(
for: trimmed,
context: context,
providerId: effectiveProviderId
)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
prompt = TranslationPrompt.make(
@@ -205,13 +181,17 @@ public actor PolishingService {
}
}
let budget = effectiveTimeout(for: trimmed)
// The HTTP request itself uses `budget`; the safety-net timer is
// given a small slack on top so a clean URL timeout surfaces its
// (more specific) transport error before the race fires.
let safetyNet = budget + 2
return try await withThrowingTaskGroup(of: String.self) { group in
group.addTask {
try await client.polish(trimmed, systemPrompt: prompt)
try await client.polish(trimmed, systemPrompt: prompt, timeout: budget)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(budget * 1_000_000_000))
try await Task.sleep(nanoseconds: UInt64(safetyNet * 1_000_000_000))
throw PolishError.timeout
}
let result = try await group.next()!
@@ -220,91 +200,120 @@ public actor PolishingService {
}
}
/// Build the one-step "intelligent" prompt. The structure is:
/// 1. Role
/// 2. Three numbered tasks (correction, polish, style)
/// 3. Hard rules (do-not-modify list, length cap, short-circuit)
/// 4. User dictionary block (if any)
/// 5. Context + intensity guidelines
/// 6. Optional preceding text
/// 7. The transcript to process
/// 8. Output contract
///
/// The Chinese / English split mirrors `shouldUseChineseGuidance` so
/// the polish step stays in the provider's strongest language.
internal func buildPrompt(for text: String, context: PolishContext) -> String {
/// Shared output contract injected into every polish prompt.
internal static func globalOutputContract(useChinese: Bool) -> String {
if useChinese {
return """
## 全局输出契约(所有润色档位均必须遵守,优先级最高)
1. **禁止新增 emoji**:原文无 emoji 时输出不得出现 emoji;原文有 emoji 时仅可原样保留。
2. **必须恢复合理标点**:逗号、句号、问号、感叹号;按语义分句,不要输出无标点长段。
3. **必须做内容触发型结构化**(所有档位):
- 「第一点/第二个/步骤一/一是二是三是」→ 转为 `1. ` 编号列表并换行
- 「首先/其次/最后/另外/一方面」→ 分段换行,不强行编号
- 待办、会议纪要、多个问题、长文本多句 → 按语义分段
- 短但含结构信号的文本仍要格式化;极短且无结构的已由系统跳过
4. **数字要结合上下文判断**(重要):
- 有意义的数字(价格、日期、数量、时间、电话、版本号)→ 保持不变
- 但语音里的序号常被误识别成数字或时间,需结合上下文修回并列表化:
· 已出现「第一点」,随后的「第2:00 / 第2点0 / 第二零零」多半是「第二点」,「第3:00」多半是「第三点」
· 「1、2、3」「一、二、三」在列举语境里就是序号,转成 `1. ` 列表
- 判断依据是上下文里是否在“分点/列举”,不要机械地保留听错的数字
5. **保守改写**:能加标点就不改词;能分段就不重写;能小改就不大改;不新增事实。
6. **不改**人名、地名、专有名词(除非 ASR 明显错误)。
7. 输出语言必须与原文一致;不翻译、不扩写成 AI 文案。
8. 只输出最终文本:不要解释、不要引号包裹、不要前缀说明。
"""
} else {
return """
## Global output contract (mandatory at every intensity — highest priority)
1. **No new emojis**: if the original has none, output must have none; preserve originals only.
2. **Restore proper punctuation**: commas, periods, question marks; break run-on speech into sentences.
3. **Content-triggered structure** (every intensity):
- "first point / second / step one / one is two is three" → numbered `1. ` list with line breaks
- "firstly / secondly / finally / on the other hand" → paragraph breaks, not forced numbering
- todos, meeting notes, multiple questions, long multi-clause speech → semantic paragraphs
4. **Judge numbers by context** (important):
- Meaningful numbers (prices, dates, quantities, times, phone numbers, versions) → keep unchanged.
- But spoken ordinals are often misrecognized as digits/times; use context to restore and listify:
· after a "first point", a following "2:00 / point 2 / two oh oh" is likely "second point", "3:00" is "third point"
· "1, 2, 3" or "one, two, three" in an enumerating context are ordinals → convert to a `1. ` list
- Decide by whether the context is enumerating; do not mechanically preserve a misheard number.
5. **Conservative rewrite**: prefer punctuation over rewording; prefer breaks over rewriting; minimal changes.
6. **Do not** alter person names, places, or proper nouns unless clearly misrecognized.
7. Output language must match the input; do not translate or expand into marketing copy.
8. Output the final text only: no explanation, no quotes, no preamble.
"""
}
}
internal func buildPrompt(
for text: String,
context: PolishContext,
providerId: String
) -> String {
let dictionary = store.personalDictionary
let dictionaryBlock = dictionary.promptFragment()
let contextGuideline = context.appContext.polishGuideline
let intensityGuideline = context.intensity.promptGuideline
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
let precedingBlock = context.precedingForPrompt
.map { "上文(仅供参考,**不要**改写):\n\($0)\n" } ?? ""
let useChinese = shouldUseChineseGuidance(providerId: store.providerId)
.map {
"""
## 上文(仅供参考 — 用于术语/语气/是否续接列表或换行;**禁止**改写上文,**禁止**从上文新增事实)
\($0)
"""
} ?? ""
let useChinese = shouldUseChineseGuidance(providerId: providerId)
if useChinese {
return """
你是智能语音输入法的后处理引擎。一次完成三件事
你是智能语音输入法的后处理引擎。一次完成:ASR 纠错、标点恢复、语义分段、按档位润色。
\(contract)
## 任务 1:纠错
- 修正明显的语音识别错误(同音字、近音字、漏字、错字)
- 修正专有名词、英文术语(参考下面的用户词典)
- **绝不**修改数字、人名、地名(除非明显错得离谱)
## 任务 2:润色
- 删除冗余的语气词(嗯、呃、那个、就是、然后、对、ok)
- 删除重复说错的字句
- 必要时调整语序让表达更通顺
- 加合适的标点
## 任务 2:标点与结构
- 恢复合理标点与句子边界
- 识别口语中的列表、步骤、分点、会议纪要结构并格式化
- 长文本按语义换行分段
## 任务 3:风格适配
## 任务 3:润色(按档位)
当前输入场景:\(context.appContext.rawValue)
风格要求:\(contextGuideline)
润色档位:\(intensityGuideline)
## 重要规则
1. **最小改动原则**:原文已经能听懂的部分不要重写
2. 保留说话人的口吻和意图
3. 不添加原文中没有的信息
4. 短句(≤ 8 个中文字符 或 ≤ 15 个英文字符)直接原样返回,不要润色
5. 输出语言必须与原文一致
\(dictionaryBlock.isEmpty ? "" : "## 用户词典(必须原样保留,禁止改写)\n\(dictionaryBlock)\n")
\(precedingBlock)
## 原文
\(precedingBlock)## 原文
\(text)
请直接输出处理后的文本,**不要任何解释**。
"""
} else {
return """
You are the post-processing engine of a voice-input keyboard. Complete three tasks in one pass:
You are the post-processing engine of a voice-input keyboard. In one pass: fix ASR errors, restore punctuation, structure content, and polish per intensity.
\(contract)
## Task 1: Correction
- Fix obvious speech-recognition errors (homophones, near-misses, missing/extra characters).
- Correct proper nouns, English terms, and technical identifiers (see the user dictionary below).
- **Never** alter numbers, person names, or place names unless clearly wrong.
## Task 2: Polish
- Remove redundant filler words (um, uh, like, you know, basically).
- Remove duplicated fragments the speaker self-corrected.
- Adjust obviously broken word order.
- Add appropriate punctuation and capitalization.
## Task 2: Punctuation and structure
- Restore proper punctuation and sentence boundaries.
- Detect oral lists, steps, enumerated points, meeting-note structure and format them.
- Break long speech into semantic paragraphs.
## Task 3: Style adaptation
## Task 3: Polish (per intensity)
Current input context: \(context.appContext.rawValue)
Style guideline: \(contextGuideline)
Polish intensity: \(intensityGuideline)
## Hard rules
1. Minimum-change principle: do not rewrite parts the user already said clearly.
2. Preserve the speaker's voice and intent.
3. Never add information that is not in the original.
4. Short inputs (≤ 15 English words or ≤ 8 CJK characters) must be returned verbatim.
5. Output language must match the input language.
\(dictionaryBlock.isEmpty ? "" : "## User dictionary (must be preserved verbatim)\n\(dictionaryBlock)\n")
\(precedingBlock)
## Original transcript
\(precedingBlock)## Original transcript
\(text)
Output the processed text directly. **No explanation, no quotes, no preamble.**
@@ -312,7 +321,6 @@ public actor PolishingService {
}
}
/// Chinese-native LLM providers get a Chinese prompt, English ones get English.
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
@@ -322,19 +330,35 @@ public actor PolishingService {
}
}
/// 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
/// Per-request HTTP timeout, scaled with transcript length. This is
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
/// dictations (which generate long, listified, multi-paragraph output)
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
/// ~10 s per 100 characters, capped at 120 s.
///
/// Previously this value was computed but only used for the safety-net
/// timer while the URLRequest stayed pinned at 15 s the scaling was
/// dead code and long transcripts timed out, falling back to the raw
/// (unpolished, unsegmented) ASR text.
internal func effectiveTimeout(for text: String) -> TimeInterval {
let scaled = timeout + (Double(text.count) / 100.0) * 10.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 resolvedProviderId(
store: AppGroupStore,
providerIdOverride: String?
) -> String {
if let providerIdOverride {
return providerIdOverride
}
if store.engineMode == "local" {
return "deepseek"
}
let id = store.providerId
return id == "deepseek" ? "openai" : id
}
internal static func resolveLLMEndpoint(
store: AppGroupStore,
preset: LLMProvider,
@@ -344,10 +368,6 @@ public actor PolishingService {
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)
}
@@ -361,7 +381,7 @@ extension PolishingService.PolishError: LocalizedError {
case .timeout:
return "LLM polish timed out."
case .missingAPIKey:
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
return "Missing API key (cloud: Settings API key; local: build configuration)."
}
}
}
@@ -0,0 +1,14 @@
// PreconfiguredKeys.local.swift.example
// Copy to PreconfiguredKeys.local.swift (gitignored) before building.
// `./Scripts/generate-xcodeproj.sh` creates PreconfiguredKeys.local.swift
// from this file automatically when it is missing.
//
// The DeepSeek key is used ONLY by the local engine's built-in polish step.
// Do not commit the real key — keep it in PreconfiguredKeys.local.swift on
// your machine only.
import Foundation
enum PreconfiguredKeysLocal {
static let deepseek = "TODO_FILL_LATER_DEEPSEEK_KEY"
}
@@ -1,19 +1,14 @@
// 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.
// Built-in API keys for engine-specific polish vendors. The local engine
// pins DeepSeek; the actual key lives in `PreconfiguredKeys.local.swift`
// (gitignored) so it never ships in the public repo.
//
// 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.
// `./Scripts/generate-xcodeproj.sh` copies
// `PreconfiguredKeys.local.swift.example` `PreconfiguredKeys.local.swift`
// on first run. Replace the placeholder in the local file before
// distributing a build that uses the local engine.
import Foundation
@@ -22,29 +17,33 @@ public enum PreconfiguredKeys {
/// 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"
/// DeepSeek API key for the local engine's built-in polish step.
public static var deepseek: String {
PreconfiguredKeysLocal.deepseek
}
public static var isDeepseekConfigured: Bool {
deepseek != placeholder && !deepseek.isEmpty
}
#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 = {
public static let debugDeepseekTripwire: Bool = {
assert(
deepseek != placeholder,
"DeepSeek preconfigured key not filled — replace TODO_FILL_LATER_DEEPSEEK_KEY in PreconfiguredKeys.swift before building"
isDeepseekConfigured,
"DeepSeek preconfigured key not filled — copy PreconfiguredKeys.local.swift.example to PreconfiguredKeys.local.swift and set your key"
)
return deepseek != placeholder
return isDeepseekConfigured
}()
/// 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
_ = debugDeepseekTripwire
}
#endif
}
@@ -0,0 +1,296 @@
// TranscriptPostProcessor.swift
// OSGKeyboard · Shared
//
// Deterministic post-processing after the LLM polish step. The LLM
// handles semantic punctuation and structure; this module enforces
// hard output constraints (emoji ban, list normalization, quality
// gate) and decides when ultra-short inputs can skip the LLM entirely.
import Foundation
public enum TranscriptPostProcessor: Sendable {
/// Result of the quality gate applied to LLM output.
public enum GateDecision: Equatable, Sendable {
case accept(String)
case fallback(String)
}
// MARK: - Short-circuit gate (skip LLM)
/// Returns `true` when the transcript is short enough and lacks
/// structural signals so calling the LLM would add latency without
/// meaningful benefit (e.g. "", "OK", "").
public static func shouldSkipLLM(for text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if hasStructureSignal(in: trimmed) { return false }
let cjkCount = trimmed.unicodeScalars.filter(isCJKScalar).count
if cjkCount > 0 {
// e.g. , , ,
return trimmed.count <= 4 && cjkCount <= 4
}
// e.g. OK, yes, thanks single short token only
let words = trimmed.split(whereSeparator: { $0.isWhitespace })
return words.count == 1 && trimmed.count <= 10
}
/// Local-only cleanup when the LLM is skipped. Keeps the speaker's
/// words verbatim no punctuation invention beyond trimming.
public static func localClean(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Post-LLM pipeline
/// Apply deterministic cleanup and quality gate to LLM output.
public static func process(original: String, llmOutput: String) -> String {
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
let decision = qualityGate(original: trimmedOriginal, candidate: llmOutput)
switch decision {
case .accept(let text):
return text
case .fallback(let text):
return text
}
}
/// Quality gate: clean the LLM output deterministically.
///
/// Design note: earlier revisions reverted to the *raw ASR*
/// transcript when numbers changed or the text grew "too much".
/// That was wrong listifying and correcting ASR mis-hearings
/// (e.g. "2:00" "") legitimately change the number set,
/// so the heuristic threw away good output and re-inserted the raw,
/// mis-heard transcript (the worst possible text). We now only fall
/// back when the model returned genuinely unusable output (empty, or
/// pure explanation), and even then we prefer a cleaned candidate
/// over the raw transcript.
public static func qualityGate(original: String, candidate: String) -> GateDecision {
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
return .fallback(localClean(original))
}
text = stripExplanatoryPrefix(from: text)
text = unwrapSurroundingQuotes(text)
text = stripAddedEmojis(original: original, output: text)
text = repairMidSentenceLineBreaks(text)
text = normalizeWhitespaceAndPunctuation(text)
text = normalizeNumberedLists(text)
// If cleanup emptied the candidate (e.g. it was only an
// explanatory prefix), fall back to the trimmed original rather
// than the raw ASR that is still the least-bad option here.
if text.isEmpty {
return .fallback(localClean(original))
}
return .accept(text)
}
// MARK: - Structure detection
/// Whether the transcript contains oral enumeration / section cues.
public static func hasStructureSignal(in text: String) -> Bool {
let patterns = [
#"第[一二三四五六七八九十\d]+[点个条段步部分]"#,
#"步骤[一二三四五六七八九十\d]+"#,
#"[一二三四五六七八九十]+是"#,
#"首先|其次|再次|最后|另外|再者|一方面|另一方面"#,
#"\b(first|second|third|fourth|fifth|finally|next|another)\b"#,
#"\b(step\s*(one|two|three|four|five|\d+))\b"#,
#"point\s*(one|two|three|four|five|\d+)"#,
]
for pattern in patterns {
if text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil {
return true
}
}
return false
}
// MARK: - Emoji
/// Remove emojis from output when the original had none; otherwise
/// keep only emojis that appeared in the original.
public static func stripAddedEmojis(original: String, output: String) -> String {
let originalEmojis = Set(extractEmojis(from: original))
if originalEmojis.isEmpty {
return removeAllEmojis(from: output)
}
return String(output.unicodeScalars.filter { scalar in
if isEmojiScalar(scalar) {
return originalEmojis.contains(String(scalar))
}
return true
})
}
// MARK: - List normalization
/// Matches a line that begins with any list marker we recognize
/// (bullet, arabic number, X, X).
static let listLinePattern =
#"^\s*(?:[-*•]|\d+[.))、]|第[一二三四五六七八九十\d]+[点.))、]|步骤[一二三四五六七八九十\d]+[.))、]?)\s+"#
/// Whether a line is a list item.
static func isListLine(_ text: String) -> Bool {
text.range(of: listLinePattern, options: .regularExpression) != nil
}
/// Normalize heterogeneous numbered-list markers to `1. ` style.
public static func normalizeNumberedLists(_ text: String) -> String {
var lines = text.components(separatedBy: .newlines)
var listIndex = 0
var inList = false
for i in lines.indices {
let line = lines[i]
guard let range = line.range(of: listLinePattern, options: .regularExpression) else {
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
inList = false
listIndex = 0
}
continue
}
let content = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)
if !inList { listIndex = 0 }
listIndex += 1
inList = true
lines[i] = "\(listIndex). \(content)"
}
return lines.joined(separator: "\n")
}
// MARK: - Mid-sentence line-break repair
/// Join line breaks that split a sentence. A newline is kept only
/// when it is a paragraph break (blank line), a list boundary, or
/// the previous line ends with a sentence terminator. Otherwise the
/// break is treated as an ASR chunk-stitch artifact (e.g.
/// "\n") and merged back into one line.
public static func repairMidSentenceLineBreaks(_ text: String) -> String {
let lines = text.components(separatedBy: "\n")
guard lines.count > 1 else { return text }
var out: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard let last = out.last else {
out.append(line)
continue
}
let prevTrimmed = last.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || prevTrimmed.isEmpty
|| isListLine(trimmed) || isListLine(prevTrimmed)
|| endsWithSentenceTerminator(prevTrimmed) {
out.append(line)
continue
}
out[out.count - 1] = prevTrimmed + joinGlue(prev: prevTrimmed, next: trimmed) + trimmed
}
return out.joined(separator: "\n")
}
// MARK: - Whitespace / punctuation cleanup
public static func normalizeWhitespaceAndPunctuation(_ text: String) -> String {
var result = text
// Collapse 3+ newlines to 2.
while result.contains("\n\n\n") {
result = result.replacingOccurrences(of: "\n\n\n", with: "\n\n")
}
// Collapse duplicate Chinese / Western punctuation.
let dupPairs = [
("。。", ""), (",,", ""), ("??", ""), ("!!", ""),
("..", "."), (",,", ","), ("??", "?"), ("!!", "!"),
]
for (dup, single) in dupPairs {
while result.contains(dup) {
result = result.replacingOccurrences(of: dup, with: single)
}
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Prefix / quote cleanup
public static func stripExplanatoryPrefix(from text: String) -> String {
let prefixes = [
"以下是", "处理后", "处理后的文本", "输出如下", "结果如下",
"Here is", "Here's", "Output:", "Result:", "Processed text:",
]
var result = text
for prefix in prefixes {
if result.hasPrefix(prefix) {
result = String(result.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
if result.hasPrefix(":") || result.hasPrefix("") {
result = String(result.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}
return result
}
public static func unwrapSurroundingQuotes(_ text: String) -> String {
guard text.count >= 2 else { return text }
let pairs: [(Character, Character)] = [("\"", "\""), ("'", "'"), ("", ""), ("", "")]
for (open, close) in pairs {
if text.first == open, text.last == close {
return String(text.dropFirst().dropLast())
}
}
return text
}
// MARK: - Private helpers
private static func endsWithSentenceTerminator(_ text: String) -> Bool {
guard let last = text.unicodeScalars.last else { return false }
let terminators: Set<Unicode.Scalar> = [
"", "", "", "", "!", "?", ".", ";", "", "", ":",
]
return terminators.contains(last)
}
/// Decide the glue between two merged fragments: a space only when
/// both sides are ASCII alphanumeric (English words); nothing for CJK.
private static func joinGlue(prev: String, next: String) -> String {
guard let p = prev.unicodeScalars.last, let n = next.unicodeScalars.first else { return "" }
let alphanumerics = CharacterSet.alphanumerics
let pAscii = p.isASCII && alphanumerics.contains(p)
let nAscii = n.isASCII && alphanumerics.contains(n)
return (pAscii && nAscii) ? " " : ""
}
private static func extractEmojis(from text: String) -> [String] {
text.unicodeScalars.filter(isEmojiScalar).map { String($0) }
}
private static func removeAllEmojis(from text: String) -> String {
String(text.unicodeScalars.filter { !isEmojiScalar($0) })
.replacingOccurrences(of: " ", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func isEmojiScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.properties.isEmoji && (scalar.value > 0x238C || scalar.properties.isEmojiPresentation)
}
private static func isCJKScalar(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
return true
default:
return false
}
}
}
+6 -5
View File
@@ -5,7 +5,8 @@
"engine.asr.appleSpeech" = "Apple SpeechAnalyzer";
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
"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).";
"flow.warning.cloudPolishMissingKey" = "Cloud polish needs an API key in Settings. Inserted raw ASR text.";
"flow.warning.localPolishUnavailable" = "Built-in polish is unavailable. Inserted raw ASR text.";
/* LLM providers */
"provider.openai" = "OpenAI";
@@ -54,10 +55,10 @@
"polish.intensity.light" = "Light";
"polish.intensity.medium" = "Medium";
"polish.intensity.heavy" = "Heavy";
"polish.intensity.off.desc" = "Insert the raw ASR transcript with no LLM call.";
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications.";
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Default.";
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs. Best for meeting notes and reports.";
"polish.intensity.off.desc" = "No polish; inserts raw ASR unless your personal dictionary has entries, then runs ASR correction only.";
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications. Punctuation and structure still apply.";
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Punctuation and structure always apply. Default.";
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs when needed. Best for meeting notes and reports.";
/* v0.3.0: Detected app context labels */
"appContext.code" = "Code";
@@ -5,7 +5,8 @@
"engine.asr.appleSpeech" = "Apple 语音识别";
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
"flow.warning.cloudPolishMissingKey" = "云端润色/翻译需要 DeepSeek API Key,本次已插入原始识别结果。本地引擎请在 PreconfiguredKeys.swift 配置;云端引擎请在设置中填写 API Key。";
"flow.warning.cloudPolishMissingKey" = "云端润色需要先在设置中填写 API Key,本次已插入原始识别结果。";
"flow.warning.localPolishUnavailable" = "内置润色暂不可用,本次已插入原始识别结果。";
/* LLM providers */
"provider.openai" = "OpenAI";
@@ -54,9 +55,9 @@
"polish.intensity.light" = "轻度";
"polish.intensity.medium" = "中度";
"polish.intensity.heavy" = "深度";
"polish.intensity.off.desc" = "不调用 LLM,直接插入识别原文。";
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误。";
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句。推荐默认。";
"polish.intensity.off.desc" = "不润色;词库为空时直接插入识别原文,有词条时仅做 ASR 纠错。";
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误;标点和结构化仍会生效。";
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句;始终补标点与结构化。推荐默认。";
"polish.intensity.heavy.desc" = "可重组段落、拆长句、自动编号。适合会议纪要与报告。";
/* v0.3.0: 输入场景标签 */