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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: 输入场景标签 */
|
||||
|
||||
Reference in New Issue
Block a user