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
@@ -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] = []