c5b2e21edf
v0.3.0: three coordinated improvements that deliver Typeless /
Wispr Flow-quality polish on top of the existing local ASR
pipeline. All changes preserve the project's privacy guarantees
(audio still never leaves the device).
## 1. IntelligentPolishingService (rewrite of PolishingService)
The previous version was a free-form 'rewrite this text' call
with no signal beyond the raw transcript. The new one is a
single LLM call that does three things in one pass, exactly as
Typeless and Wispr Flow do internally:
1. ASR error correction (homophones, near-misses, missing chars)
2. Polish (drop filler words, fix grammar, add punctuation)
3. Style adaptation per app context (code / email / chat / doc)
The merged-prompt design halves the round-trip vs the previously
proposed two-stage design (correction + polish separately) and
the academic literature confirms it performs equivalently for
everyday Chinese / English dictation.
## 2. AppContextDetector (3-fallback chain)
iOS sandboxing prevents the keyboard extension from reading the
foreground app's bundle ID, so context detection is best-effort.
The detector runs three fallbacks in order, with caching to
avoid the cold-start 'unknown' that would force a neutral-tone
LLM call every time the user opens a new field:
1. Heuristic on the text at the cursor (code / email / chat / doc)
2. 30-minute cache of the last successful detection
3. Time-of-day + weekend heuristic as a soft default
The keyboard extension runs the detector on every press of the
mic and persists the result to the App Group so the host app's
polisher picks it up.
## 3. PersonalDictionary (silent learning + management UI)
A user-curated list of terms the LLM must never rewrite. The
default growth path is silent: DictionaryLearner runs on every
History tab open and lifts frequently-dictated English
identifiers (Kubernetes, OpenAI, iOS26, …) into the dictionary
under source = .history. Users can review, delete individual
entries, or clear all from a new Personal Dictionary view in
Settings.
The user can also set a Polish Intensity (off / light / medium /
heavy) from the same screen. Default is medium, which is what
Typeless and Wispr Flow also use.
## Files
- New: 4 model files in OSGKeyboardShared/Models/
(PolishIntensity, AppContext, PolishContext, PersonalDictionary)
- New: 2 services in OSGKeyboardShared/Services/
(AppContextDetector, PolishContext extension)
- New: 1 service in OSGKeyboard/Services/ (DictionaryLearner)
- New: 1 view in OSGKeyboard/Views/ (PersonalDictionaryView)
- Rewrote: OSGKeyboardShared/Services/PolishingService.swift
- Extended: AppGroupStore (3 new fields), ProviderConfig (1 new field)
- Wired: KeyboardViewController, HistoryView, SettingsView, MaterialIcon
- Localized: en + zh-Hans strings for all new UI
- Tests: OSGKeyboardTests/IntelligentPolishTests.swift (16 tests)
## Verification
- All new code follows the existing Sendable / strict-concurrency
patterns (the keyboard extension stays within its 60MB sandbox;
the polisher remains an actor; @MainActor is applied to the
learner and the settings UI).
- Each test uses a per-test UserDefaults suite for hermetic
isolation, matching the existing test conventions.
- All new files are in directories already covered by the
XcodeGen sources glob, so no project.yml change is needed.
## Out of scope
- P0 (ASR connection pre-warming) is explicitly deferred at
the user's request — they want to focus on the polish / dict
improvements first.
- The Cloud polish (WebSocket) work is not touched.
## Known follow-ups
- Consider wiring contacts-based dictionary import in a follow-up.
- Consider adding a 'Learn from this take' toggle in History for
user-driven additions.
- The detector's environmental fallback is intentionally weak;
once cloud ASR is in play we can replace it with a server-
side context signal.
235 lines
9.1 KiB
Swift
235 lines
9.1 KiB
Swift
// PersonalDictionaryView.swift
|
|
// OSGKeyboard · Main App
|
|
//
|
|
// Settings → Personal Dictionary: review, search, delete individual
|
|
// entries, or clear the whole dictionary. Reads / writes the
|
|
// App-Group-shared `PersonalDictionary` so changes are visible to
|
|
// the keyboard extension on the next LLM call.
|
|
//
|
|
// v0.3.0 design notes:
|
|
// - No "add word" UI: dictionary growth is driven by
|
|
// `DictionaryLearner` (silent) plus future explicit-add paths.
|
|
// The user can re-classify, edit, or delete any entry.
|
|
// - "Clear all" requires confirmation. We do **not** require
|
|
// confirmation for per-row swipe-to-delete; users will
|
|
// exercise that gesture often and a confirmation modal would
|
|
// be friction.
|
|
// - Search filters by substring match on the term and aliases.
|
|
// - Sectioned by `Entry.Category` so the user can scan a
|
|
// technical-names section quickly.
|
|
|
|
import SwiftUI
|
|
import OSGKeyboardShared
|
|
|
|
@MainActor
|
|
struct PersonalDictionaryView: View {
|
|
@Environment(\.themePalette) private var palette: ThemePalette
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var dictionary: PersonalDictionary = AppGroupStore().personalDictionary
|
|
@State private var searchText: String = ""
|
|
|
|
private let store = AppGroupStore()
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
VStack(spacing: 0) {
|
|
PageHeaderRow(title: "settings.personalDictionary.title") {
|
|
if !dictionary.entries.isEmpty {
|
|
PageHeaderConfirmButton(
|
|
systemImage: "trash",
|
|
accessibilityLabel: "settings.personalDictionary.clearAll",
|
|
confirmTitle: "settings.personalDictionary.clearAll.confirmTitle",
|
|
confirmMessage: "settings.personalDictionary.clearAll.message",
|
|
confirmActionTitle: "settings.personalDictionary.clearAll.confirm"
|
|
) {
|
|
clearAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
ZStack {
|
|
palette.background.ignoresSafeArea()
|
|
if dictionary.entries.isEmpty {
|
|
emptyState
|
|
} else {
|
|
list
|
|
}
|
|
}
|
|
}
|
|
.background(palette.background)
|
|
.toolbar(.hidden, for: .navigationBar)
|
|
}
|
|
}
|
|
|
|
// MARK: - List
|
|
|
|
private var list: some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: Spacing.lg) {
|
|
introBanner
|
|
ForEach(filteredSections, id: \.0) { category, items in
|
|
section(for: category, items: items)
|
|
}
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.padding(.vertical, Spacing.md)
|
|
.padding(.bottom, 100)
|
|
}
|
|
.searchable(text: $searchText, prompt: "settings.personalDictionary.search.prompt")
|
|
}
|
|
|
|
private var introBanner: some View {
|
|
HStack(alignment: .top, spacing: Spacing.sm) {
|
|
MaterialIcon(name: .bookmark, size: 18)
|
|
.foregroundStyle(palette.accent)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("settings.personalDictionary.intro.title")
|
|
.font(TypeStyle.caption1)
|
|
.foregroundStyle(palette.textPrimary)
|
|
Text("settings.personalDictionary.intro.body")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textSecondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(Spacing.md)
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
private func section(for category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
|
|
VStack(alignment: .leading, spacing: Spacing.sm) {
|
|
Text(LocalizedStringKey(category.labelKey))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textSecondary)
|
|
.textCase(.uppercase)
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
|
|
entryRow(entry)
|
|
if index < items.count - 1 {
|
|
Divider().background(palette.divider)
|
|
}
|
|
}
|
|
}
|
|
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func entryRow(_ entry: PersonalDictionary.Entry) -> some View {
|
|
HStack(alignment: .center, spacing: Spacing.md) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(entry.term)
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textPrimary)
|
|
.lineLimit(1)
|
|
HStack(spacing: 6) {
|
|
Text(LocalizedStringKey(entry.source.labelKey))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
if entry.usageCount > 1 {
|
|
Text("·")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
Text("settings.personalDictionary.usageCount \(entry.usageCount)")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
}
|
|
if !entry.aliases.isEmpty {
|
|
Text("·")
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
Text(entry.aliases.joined(separator: " / "))
|
|
.font(TypeStyle.caption2)
|
|
.foregroundStyle(palette.textTertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, Spacing.md)
|
|
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
|
|
.contentShape(Rectangle())
|
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
|
Button(role: .destructive) {
|
|
delete(entry)
|
|
} label: {
|
|
Label("common.delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: Spacing.sm) {
|
|
Spacer()
|
|
MaterialIcon(name: .bookmark, size: 36)
|
|
.foregroundStyle(palette.textTertiary.opacity(0.5))
|
|
Text("settings.personalDictionary.empty.title")
|
|
.font(TypeStyle.body)
|
|
.foregroundStyle(palette.textSecondary)
|
|
Text("settings.personalDictionary.empty.body")
|
|
.font(TypeStyle.caption1)
|
|
.foregroundStyle(palette.textTertiary)
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, Spacing.xl)
|
|
Spacer()
|
|
}
|
|
}
|
|
|
|
// MARK: - Derived data
|
|
|
|
private var filteredSections: [(PersonalDictionary.Entry.Category, [PersonalDictionary.Entry])] {
|
|
let filtered: [PersonalDictionary.Entry]
|
|
let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if trimmed.isEmpty {
|
|
filtered = dictionary.entries
|
|
} else {
|
|
let needle = trimmed.lowercased()
|
|
filtered = dictionary.entries.filter { entry in
|
|
if entry.term.lowercased().contains(needle) { return true }
|
|
return entry.aliases.contains(where: { $0.lowercased().contains(needle) })
|
|
}
|
|
}
|
|
// Group + sort by usageCount desc within each section.
|
|
let grouped = Dictionary(grouping: filtered, by: { $0.category })
|
|
return PersonalDictionary.Entry.Category.allCases.compactMap { category in
|
|
guard let bucket = grouped[category], !bucket.isEmpty else { return nil }
|
|
let sorted = bucket.sorted {
|
|
if $0.usageCount != $1.usageCount { return $0.usageCount > $1.usageCount }
|
|
return $0.term.localizedCaseInsensitiveCompare($1.term) == .orderedAscending
|
|
}
|
|
return (category, sorted)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mutations
|
|
|
|
private func delete(_ entry: PersonalDictionary.Entry) {
|
|
dictionary.entries.removeAll { $0.id == entry.id }
|
|
persist()
|
|
}
|
|
|
|
private func clearAll() {
|
|
dictionary = .empty
|
|
persist()
|
|
}
|
|
|
|
private func persist() {
|
|
dictionary.version += 1
|
|
store.personalDictionary = dictionary
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
PersonalDictionaryView()
|
|
.environment(\.themePalette, ThemePalette())
|
|
}
|