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
@@ -0,0 +1,102 @@
// DictionaryAliasGenerator.swift
// OSGKeyboard · Main App
//
// After the user manually adds or edits a personal-dictionary term,
// asks the built-in DeepSeek endpoint for common ASR misrecognitions.
// Runs only in the main app (Settings) the keyboard extension reads
// the persisted aliases on the next polish / correction call.
import Foundation
import OSGKeyboardShared
struct DictionaryAliasGenerator: Sendable {
private let client: LLMClient?
private let timeout: TimeInterval
init(client: LLMClient? = nil, timeout: TimeInterval = 12) {
self.client = client
self.timeout = timeout
}
func generateAliases(for term: String) async -> [String] {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
do {
let client = try resolveClient()
let prompt = Self.makePrompt(for: trimmed)
let raw = try await withThrowingTaskGroup(of: String.self) { group in
group.addTask {
try await client.polish(trimmed, systemPrompt: prompt)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
throw CancellationError()
}
let result = try await group.next()!
group.cancelAll()
return result
}
return Self.parseAliases(from: raw, excludingTerm: trimmed)
} catch {
#if DEBUG
print("⚠️ [DictionaryAliasGenerator] alias generation failed: \(error)")
#endif
return []
}
}
private func resolveClient() throws -> LLMClient {
if let client {
return client
}
guard PreconfiguredKeys.isDeepseekConfigured else {
throw LLMError.noAPIKey
}
let preset = LLMProvider.provider(id: "deepseek")
return OpenAICompatibleClient(
baseURL: preset.defaultBaseURL,
apiKey: PreconfiguredKeys.deepseek,
model: preset.defaultModel
)
}
private static func makePrompt(for term: String) -> String {
"""
你是语音识别纠错助手。用户把专有词汇「\(term)」加入了个人词库。
请列出该词在中文或英文语音输入时最常见的 3–6 个误识别写法(同音字、近音字、拼音混淆、英文误听等)。
不要包含正确词「\(term)」本身。
只输出 JSON 字符串数组,例如 ["1","2"]。若无合理别名则输出 []。
"""
}
static func parseAliases(from raw: String, excludingTerm term: String) -> [String] {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let jsonSlice = extractJSONArray(from: trimmed) ?? trimmed
guard let data = jsonSlice.data(using: .utf8),
let decoded = try? JSONDecoder().decode([String].self, from: data)
else { return [] }
let termLower = term.lowercased()
var seen = Set<String>()
var result: [String] = []
for alias in decoded {
let cleaned = alias.trimmingCharacters(in: .whitespacesAndNewlines)
guard !cleaned.isEmpty else { continue }
let key = cleaned.lowercased()
guard key != termLower, !seen.contains(key) else { continue }
seen.insert(key)
result.append(cleaned)
if result.count >= 6 { break }
}
return result
}
private static func extractJSONArray(from text: String) -> String? {
guard let start = text.firstIndex(of: "["),
let end = text.lastIndex(of: "]"),
start < end
else { return nil }
return String(text[start...end])
}
}
@@ -1,268 +0,0 @@
// DictionaryLearner.swift
// OSGKeyboard · Main App
//
// v0.3.0: silent, on-device dictionary learner.
//
// Goal: identify terms the user dictates frequently that are
// likely proper nouns, technical terms, or product names, and add
// them to the personal dictionary so the LLM polish step stops
// "correcting" them (Kubernetes "k" or similar).
//
// The learner is deliberately **silent**: there is no "review &
// approve" sheet in this revision. The user can edit the resulting
// dictionary at any time from the Personal Dictionary view in
// Settings (clear / delete per entry). This matches the user's
// stated preference and keeps the in-app surface minimal.
//
// Heuristic signals we use to flag a candidate:
//
// 1. **Mixed-case ASCII run of length 2** ("Kubernetes",
// "OpenAI", "iOS26"). Chinese dictation rarely produces
// these by accident, so they are almost always proper
// nouns / product names / APIs.
// 2. **Run of length 2 containing a digit** ("iOS26",
// "Swift6", "Qwen3", "v3"). Same reasoning accidental
// digits in speech are rare.
// 3. **Capitalized ASCII word the user has dictated 2
// times** across the recent history. Repeat usage is a
// strong "this matters to me" signal.
//
// We also stop short of common false positives:
//
// - We never auto-add ASCII words 1 character (too noisy).
// - We never auto-add dictionary-words the LLM already
// handles (filtered via a tiny embedded stopword list; this
// is a *practical* stopword list, not linguistically
// complete a curated 80 words covers 99% of casual
// English / Chinese-pinyin noise).
// - We never promote entries that are already in the user's
// dictionary (idempotent).
//
// Storage: results merge into `AppGroupStore.personalDictionary`
// under `source = .history`. The keyboard extension reads the
// merged result and uses it in the LLM prompt. **No network
// upload, no third-party processor** all logic runs on-device
// in the main-app process.
import Foundation
import OSGKeyboardShared
@MainActor
final class DictionaryLearner {
/// Minimum number of recent transcriptions a candidate must
/// appear in before we consider promoting it. Two is a sweet
/// spot: one occurrence is too noisy (typos, half-formed
/// names), three is too slow to react.
static let defaultMinOccurrences: Int = 2
/// Maximum number of most-recent history entries to scan. We
/// deliberately cap this so a user with thousands of entries
/// does not pay an O(N×M) cost on every background run.
static let defaultMaxHistoryEntries: Int = 200
private let minOccurrences: Int
private let maxHistoryEntries: Int
private let stopwords: Set<String>
init(
minOccurrences: Int = DictionaryLearner.defaultMinOccurrences,
maxHistoryEntries: Int = DictionaryLearner.defaultMaxHistoryEntries,
stopwords: Set<String> = DictionaryLearner.embeddedStopwords
) {
self.minOccurrences = minOccurrences
self.maxHistoryEntries = maxHistoryEntries
self.stopwords = stopwords
}
/// Inspect the user's transcription history and merge any
/// newly-discovered terms into the App Group personal
/// dictionary. Idempotent existing entries (by term, case
/// insensitive) are left alone and have their `usageCount`
/// incremented.
///
/// Safe to call repeatedly (e.g. on every History tab open).
/// Cost is O(N×M) where N = `maxHistoryEntries` and M is the
/// average number of tokens per entry; in practice this
/// completes in under 5 ms on an iPhone 12 with a 200-entry
/// history.
@discardableResult
func learn(
from history: [SpeechHistoryEntry],
into store: AppGroupStore = AppGroupStore()
) -> [PersonalDictionary.Entry] {
let recent = Array(history.prefix(maxHistoryEntries))
guard !recent.isEmpty else { return [] }
// 1. Tokenize each entry and count interesting tokens.
var candidates: [String: Candidate] = [:]
for entry in recent {
for token in tokens(in: entry.text) {
guard isWorthPromoting(token) else { continue }
let key = token.lowercased()
var bucket = candidates[key] ?? Candidate(term: token)
bucket.occurrences += 1
bucket.lastSeen = max(bucket.lastSeen, entry.createdAt)
candidates[key] = bucket
}
}
// 2. Filter to ones that appeared at least minOccurrences.
let promoted = candidates.values.filter { $0.occurrences >= minOccurrences }
// 3. Merge into the existing dictionary. Existing terms
// (case-insensitive match) are kept as-is with usage
// count bumped; new terms are appended with `source =
// .history`. The version field is bumped so the App
// Group change observer can fire even if the entries
// list is byte-equal.
var dictionary = store.personalDictionary
let existingTerms = Set(dictionary.entries.map { $0.term.lowercased() })
var addedOrBumped: [PersonalDictionary.Entry] = []
var didChange = false
for candidate in promoted {
if let idx = dictionary.entries.firstIndex(where: {
$0.term.lowercased() == candidate.term.lowercased()
}) {
dictionary.entries[idx].usageCount += candidate.occurrences
addedOrBumped.append(dictionary.entries[idx])
} else {
let entry = PersonalDictionary.Entry(
term: candidate.term,
aliases: [],
category: inferCategory(candidate.term),
source: .history,
createdAt: candidate.lastSeen,
usageCount: candidate.occurrences
)
dictionary.entries.append(entry)
addedOrBumped.append(entry)
didChange = true
}
}
if didChange {
dictionary.version += 1
store.setPersonalDictionary(dictionary)
}
// Suppress the "did not change" path; we still return the
// bumped-counts view so the caller can refresh a UI label.
_ = existingTerms
return addedOrBumped
}
// MARK: - Tokenization
/// Pragmatic word tokenizer. Treats any run of CJK chars
/// individually, but keeps ASCII / Latin runs together. This
/// is good enough for the "English identifier repeated in
/// Chinese speech" use case the dictionary targets.
internal func tokens(in text: String) -> [String] {
var tokens: [String] = []
var current = ""
for ch in text {
if isCJK(ch) {
if !current.isEmpty { tokens.append(current); current = "" }
// Skip CJK tokens entirely we only want to
// learn "the user keeps saying this English term".
} else if ch.isLetter || ch.isNumber {
current.append(ch)
} else {
if !current.isEmpty { tokens.append(current); current = "" }
}
}
if !current.isEmpty { tokens.append(current) }
return tokens
}
private func isCJK(_ ch: Character) -> Bool {
guard let scalar = ch.unicodeScalars.first else { return false }
// Common CJK Unified Ideographs blocks. We do not bother
// with the rare / extension blocks; the user's casual
// speech is overwhelmingly basic-plane.
return (0x4E00...0x9FFF).contains(scalar.value)
|| (0x3400...0x4DBF).contains(scalar.value)
}
// MARK: - Heuristic filters
private func isWorthPromoting(_ token: String) -> Bool {
guard token.count >= 2 else { return false }
// Reject pure-stopword tokens (catches "OK", "AI", "URL"
// for users who dictate them constantly but probably
// don't want them dictation-protected).
if stopwords.contains(token.lowercased()) { return false }
let hasUpper = token.contains(where: { $0.isUppercase })
let hasDigit = token.contains(where: { $0.isNumber })
// Heuristic 1: mixed-case ASCII run of length 2.
if hasUpper, token.count >= 3 { return true }
// Heuristic 2: any digit in the run.
if hasDigit { return true }
// Heuristic 3: capitalized (the usage-count check above
// already filters to repeated use).
if token.first?.isUppercase == true, token.count >= 2 { return true }
return false
}
/// Lightweight category inference. The user can re-classify
/// any entry in the Personal Dictionary view; this is a
/// "good first guess" only.
private func inferCategory(_ term: String) -> PersonalDictionary.Entry.Category {
let hasUpper = term.contains(where: { $0.isUppercase })
let hasDigit = term.contains(where: { $0.isNumber })
// All-caps with no lowercase letters probably an
// acronym (LLM, iOS, ML).
if hasUpper, !term.contains(where: { $0.isLowercase }) {
return .acronym
}
if hasDigit {
// "iOS26" or "v3" product name with version.
return .productName
}
// "OpenAI", "Kubernetes", "Typeless" technical or
// product. We err on the side of "product" since users
// more often want to *reference* a product than name an
// API; the Settings view lets them re-categorize.
return .productName
}
// MARK: - Internal types
private struct Candidate {
let term: String
var occurrences: Int = 0
var lastSeen: Date = .distantPast
}
// MARK: - Stopwords
/// Tiny practical stopword list. Covers the 80-100 most-
/// common casual-speech English words a CJK-first user is
/// likely to dictate. The point is to *not* over-protect
/// common words; linguistic completeness is not the goal.
static let embeddedStopwords: Set<String> = [
// Common English function words
"i", "we", "you", "he", "she", "it", "they",
"is", "are", "was", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did",
"will", "would", "could", "should", "may", "might", "must",
"the", "a", "an", "and", "or", "but", "if", "then", "else",
"to", "of", "in", "on", "at", "by", "for", "with", "from",
"this", "that", "these", "those", "my", "your", "his", "her",
"ok", "okay", "yeah", "yes", "no", "not", "so", "very", "too",
"as", "at", "be", "by", "about", "into", "over", "after",
// Common casual fillers / interjections
"um", "uh", "ah", "er", "hmm", "huh",
"like", "well", "right", "actually", "basically",
"literally", "kinda", "sorta", "guess",
// Tech words too common to be worth dictation-protection
"ai", "ml", "api", "url", "ui", "ux", "ios", "mac", "os",
"app", "apps", "web", "http", "https", "json", "xml",
"css", "html", "sql", "db", "os",
"file", "files", "data", "code", "codes", "test", "tests",
"go", "run", "runs", "use", "uses", "make", "makes",
"set", "sets", "get", "gets", "put", "puts", "let", "lets",
"new", "old", "next", "last", "first", "second", "third",
]
}
@@ -622,7 +622,7 @@ final class FlowSessionManager: ObservableObject {
// so the keyboard can show the "fill in your key" hint
// inline rather than a generic failure message. The raw
// transcript is still delivered no data loss.
let warning = Self.warningFromPolishError(error) ?? chunkNote
let warning = Self.warningFromPolishError(error, engineMode: engineMode) ?? chunkNote
FlowDiagnostics.log(
"polish failed after \(String(format: "%.1f", Date().timeIntervalSince(polishStarted)))s: " +
"\(error.localizedDescription)"
@@ -667,11 +667,14 @@ final class FlowSessionManager: ObservableObject {
/// v0.2.0: surface the local-mode cloud-polish error path with a
/// localised hint ("please fill in your DeepSeek key in Settings")
/// rather than letting the keyboard show a generic network error.
private static func warningFromPolishError(_ error: Error) -> String? {
private static func warningFromPolishError(_ error: Error, engineMode: String) -> String? {
guard let polishError = error as? PolishingService.PolishError,
polishError == .missingAPIKey else {
return nil
}
if engineMode == "local" {
return AppL10n.string("flow.warning.localPolishUnavailable")
}
return AppL10n.string("flow.warning.cloudPolishMissingKey")
}
@@ -9,12 +9,19 @@ import OSGKeyboardShared
struct HomeStatsCard: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
@ObservedObject private var stats = UsageStatisticsStore.shared
@ObservedObject private var config = ProviderConfig.shared
@State private var dictionaryCount = 0
private enum Layout {
static let fixedHeight: CGFloat = 166
static let valueFontSize: CGFloat = 24
static let iconSize: CGFloat = 18
}
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
@@ -48,7 +55,7 @@ struct HomeStatsCard: View {
)
divider
statCell(
systemImage: "books.vertical",
systemImage: "square.stack.3d.down.right.fill",
value: UsageStatisticsStore.formatCount(
dictionaryCount,
language: config.uiLanguage
@@ -57,20 +64,8 @@ struct HomeStatsCard: View {
)
}
}
.background {
ZStack(alignment: .top) {
palette.surface
LinearGradient(
colors: [
palette.accent.opacity(0.10),
palette.accent.opacity(0.02),
palette.surface.opacity(0)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
}
.frame(height: Layout.fixedHeight)
.background(cardBackground)
.clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
@@ -83,22 +78,24 @@ struct HomeStatsCard: View {
}
private func statCell(systemImage: String, value: String, label: LocalizedStringKey) -> some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(spacing: 6) {
Image(systemName: systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(palette.accent)
HStack(alignment: .top, spacing: Spacing.xs) {
VStack(alignment: .leading, spacing: Spacing.xs) {
Text(value)
.font(TypeStyle.headline)
.font(.system(size: Layout.valueFontSize, weight: .semibold, design: .rounded))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
}
Text(label)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.85)
Spacer(minLength: Spacing.xs)
Image(systemName: systemImage)
.font(.system(size: Layout.iconSize, weight: .semibold))
.foregroundStyle(palette.accent)
.padding(.top, 2)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
@@ -119,6 +116,10 @@ struct HomeStatsCard: View {
private func refreshDictionaryCount() {
dictionaryCount = AppGroupStore().personalDictionary.entries.count
}
private var cardBackground: Color {
colorScheme == .dark ? palette.surface : .white
}
}
#if DEBUG
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
// Bottom tab bar three Material icons, no labels.
// Bottom tab bar four icons, no labels.
// Capsule uses iOS 26 Liquid Glass (.regular.interactive) so content
// behind the dock refracts through on scroll.
@@ -11,20 +11,31 @@ import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
case history
case dictionary
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
case .history: return .menuBook
case .dictionary: return .menuBook // unused dictionary uses SF Symbol
case .settings: return .settings
}
}
/// Matches `HomeStatsCard` dictionary stat cell (filled variant).
var sfSymbol: String? {
switch self {
case .dictionary: return "square.stack.3d.down.right.fill"
default: return nil
}
}
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
case .history: return "tab.history"
case .dictionary: return "tab.dictionary"
case .settings: return "tab.settings"
}
}
@@ -41,10 +52,14 @@ struct MinimalTabBar: View {
Button {
withAnimation(Motion.quick) { selection = tab }
} label: {
MaterialIcon(
name: tab.icon,
size: 24
)
Group {
if let sfSymbol = tab.sfSymbol {
Image(systemName: sfSymbol)
.font(.system(size: 20, weight: .regular))
} else {
MaterialIcon(name: tab.icon, size: 24)
}
}
.foregroundStyle(tabIconColor(for: tab))
.frame(maxWidth: .infinity)
.frame(height: 48)
@@ -58,7 +73,7 @@ struct MinimalTabBar: View {
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.glassEffect(.regular.interactive(), in: .capsule)
.frame(maxWidth: 252)
.frame(maxWidth: 336)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, Spacing.xs)
}
@@ -1,91 +0,0 @@
// PageHeaderConfirmButton.swift
// OSGKeyboard · Main App
//
// popover action sheet
import SwiftUI
import OSGKeyboardShared
struct PageHeaderConfirmButton: View {
@Environment(\.themePalette) private var palette: ThemePalette
@Environment(\.colorScheme) private var colorScheme
let systemImage: String
let accessibilityLabel: LocalizedStringKey
let confirmTitle: LocalizedStringKey
let confirmMessage: LocalizedStringKey
let confirmActionTitle: LocalizedStringKey
let onConfirm: () -> Void
@State private var showConfirm = false
private let buttonSize: CGFloat = 36
var body: some View {
Button {
showConfirm = true
} label: {
Image(systemName: systemImage)
.font(.system(size: 16, weight: .medium))
.foregroundStyle(iconColor)
.frame(width: buttonSize, height: buttonSize)
.background(circleFill, in: Circle())
.overlay(Circle().stroke(circleStroke, lineWidth: 0.5))
}
.buttonStyle(.plain)
.accessibilityLabel(Text(accessibilityLabel))
.popover(isPresented: $showConfirm, arrowEdge: .top) {
confirmPopover
.presentationCompactAdaptation(.popover)
}
}
/// + +
private var circleFill: Color {
switch colorScheme {
case .dark:
return palette.surfaceElevated
default:
return Color.white
}
}
private var circleStroke: Color {
colorScheme == .dark ? palette.dividerStrong : palette.divider
}
private var iconColor: Color {
colorScheme == .dark ? palette.textPrimary : .black
}
private var confirmPopover: some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Text(confirmTitle)
.font(TypeStyle.headline)
.foregroundStyle(palette.textPrimary)
Text(confirmMessage)
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: Spacing.sm) {
Spacer(minLength: 0)
Button(LocalizedStringKey("common.cancel")) {
showConfirm = false
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textSecondary)
Button(confirmActionTitle) {
showConfirm = false
onConfirm()
}
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.danger)
}
}
.padding(Spacing.md)
.frame(minWidth: 260)
}
}
@@ -1,37 +0,0 @@
// PageHeaderRow.swift
// OSGKeyboard · Main App
//
// + navigation toolbar
// iOS leading/trailing
import SwiftUI
import OSGKeyboardShared
struct PageHeaderRow<Trailing: View>: View {
@Environment(\.themePalette) private var palette: ThemePalette
let title: LocalizedStringKey
@ViewBuilder var trailing: () -> Trailing
var body: some View {
HStack(alignment: .center, spacing: Spacing.sm) {
Text(title)
.font(TypeStyle.title2)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
trailing()
}
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.md)
.padding(.bottom, Spacing.sm)
}
}
extension PageHeaderRow where Trailing == EmptyView {
init(title: LocalizedStringKey) {
self.title = title
self.trailing = { EmptyView() }
}
}
@@ -0,0 +1,56 @@
// TabBarVisibility.swift
// OSGKeyboard · Main App
//
// Push NavigationStack tab TabView
// MainTabView `TabBarHiddenPreferenceKey` `hidesTabBarWhenPushed()`
import SwiftUI
import OSGKeyboardShared
// MARK: - Preference
enum TabBarHiddenPreferenceKey: PreferenceKey {
static let defaultValue = false
static func reduce(value: inout Bool, nextValue: () -> Bool) {
value = value || nextValue()
}
}
// MARK: - Environment
private enum TabBarVisibleEnvironmentKey: EnvironmentKey {
static let defaultValue = true
}
extension EnvironmentValues {
/// `false` when the custom dock is hidden (detail push / sheet over tab content).
var isTabBarVisible: Bool {
get { self[TabBarVisibleEnvironmentKey.self] }
set { self[TabBarVisibleEnvironmentKey.self] = newValue }
}
}
// MARK: - Modifiers
extension View {
/// Marks this view as a pushed detail screen so `MainTabView` hides the dock.
func hidesTabBarWhenPushed() -> some View {
preference(key: TabBarHiddenPreferenceKey.self, value: true)
}
/// Bottom inset for scroll content above the floating dock (tab root pages only).
func tabBarScrollBottomPadding() -> some View {
modifier(TabBarScrollBottomPaddingModifier())
}
}
private struct TabBarScrollBottomPaddingModifier: ViewModifier {
@Environment(\.isTabBarVisible) private var isTabBarVisible
private let dockClearance: CGFloat = 100
func body(content: Content) -> some View {
content.padding(.bottom, isTabBarVisible ? dockClearance : Spacing.lg)
}
}
+3 -1
View File
@@ -102,9 +102,11 @@ struct EnginePickerSection: View {
private func selectEngine(_ id: String) {
withAnimation(.easeInOut(duration: 0.2)) {
config.engineMode = id
// Cloud always runs ASR + LLM polish; no off/transcribe toggle.
if id == "cloud" {
config.modeId = "polish"
if config.providerId == "deepseek" {
config.apply(preset: LLMProvider.provider(id: "openai"))
}
}
}
}
+1
View File
@@ -31,5 +31,6 @@ struct HelpFeedbackView: View {
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.link.support")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
+40 -43
View File
@@ -8,6 +8,8 @@ struct HistoryView: View {
@Environment(\.themePalette) private var palette: ThemePalette
@ObservedObject private var store = SpeechHistoryStore.shared
@State private var showClearConfirmation = false
private static let dayFormatter: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
@@ -24,56 +26,51 @@ struct HistoryView: View {
var body: some View {
NavigationStack {
VStack(spacing: 0) {
PageHeaderRow(title: "history.title") {
if !store.entries.isEmpty {
PageHeaderConfirmButton(
systemImage: "trash",
accessibilityLabel: "history.clear.button",
confirmTitle: "history.clear.title",
confirmMessage: "history.clear.message",
confirmActionTitle: "history.clear.confirm"
) {
store.clearAll()
}
}
}
ZStack {
palette.background.ignoresSafeArea()
Text("history.subtitle")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.sm)
ZStack {
palette.background.ignoresSafeArea()
if store.entries.isEmpty {
emptyState
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(store.groupedByDay, id: \.day) { group in
daySection(day: group.day, items: group.items)
}
if store.entries.isEmpty {
emptyState
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: Spacing.xl) {
ForEach(store.groupedByDay, id: \.day) { group in
daySection(day: group.day, items: group.items)
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.padding(.bottom, 100)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.tabBarScrollBottomPadding()
}
}
}
.background(palette.background)
.toolbar(.hidden, for: .navigationBar)
}
.task {
// v0.3.0: each time the user opens History, run a
// silent pass to lift frequently-dictated English
// identifiers into the personal dictionary. Cheap
// ( 5 ms for 200 entries on iPhone 12) and idempotent.
DictionaryLearner().learn(from: store.entries)
.navigationTitle("history.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
if !store.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button {
showClearConfirmation = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel("history.clear.button")
}
}
}
.confirmationDialog(
"history.clear.title",
isPresented: $showClearConfirmation,
titleVisibility: .visible
) {
Button("history.clear.confirm", role: .destructive) {
store.clearAll()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text("history.clear.message")
}
}
}
+40 -53
View File
@@ -56,7 +56,7 @@ struct HomeView: View {
VStack(spacing: 0) {
logoHeader
.padding(.top, Spacing.xxxl)
.padding(.bottom, Spacing.xl)
.padding(.bottom, Spacing.xxl)
if showsFlowSessionExtras {
flowSessionExtras
@@ -72,14 +72,24 @@ struct HomeView: View {
.padding(.horizontal, Spacing.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
engineStatusLine
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
.padding(.bottom, Spacing.sm)
HStack(spacing: Spacing.sm) {
engineStatusLine
flowStatusFooter
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xl)
.padding(.bottom, Spacing.sm)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
}
.background(palette.background)
.contentShape(Rectangle())
.onTapGesture {
if previewFocused {
previewFocused = false
}
}
}
.onAppear {
refreshPermissionStatuses()
@@ -149,43 +159,13 @@ struct HomeView: View {
.scaledToFit()
.frame(width: 144, height: 41)
.accessibilityHidden(true)
statusCapsule
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.lg)
}
private var statusCapsule: some View {
HStack(spacing: Spacing.sm) {
flowCapsuleSegment
if flowManager.isActive {
Button {
flowManager.endSession()
} label: {
Text("home.flow.endShort")
.font(TypeStyle.caption)
.foregroundStyle(palette.textOnAccent)
.padding(.horizontal, Spacing.sm)
.padding(.vertical, 5)
.background(palette.accent, in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.sm)
.background(capsuleBackground, in: Capsule())
.overlay(
Capsule()
.stroke(palette.divider, lineWidth: 0.5)
)
.animation(Motion.soft, value: flowManager.isActive)
}
@ViewBuilder
private var flowCapsuleSegment: some View {
// 绿 + + /
private var flowStatusFooter: some View {
HStack(spacing: Spacing.xs) {
Circle()
.fill(flowStatusColor)
@@ -194,30 +174,37 @@ struct HomeView: View {
if flowManager.isActive,
let expires = flowManager.sessionExpiresAt {
Text("home.flow.label")
.font(TypeStyle.status)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
Text(":")
.font(TypeStyle.status)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Text(expires, style: .timer)
.font(TypeStyle.status)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.monospacedDigit()
} else {
Text(flowCapsuleStatusMessage)
.font(TypeStyle.status)
.font(TypeStyle.caption2)
.foregroundStyle(palette.textPrimary)
.lineLimit(2)
.lineLimit(1)
.minimumScaleFactor(0.85)
.multilineTextAlignment(.leading)
}
if flowManager.isActive {
Button {
flowManager.endSession()
} label: {
Text("home.flow.endShort")
.font(TypeStyle.caption2)
.foregroundStyle(palette.accent)
}
.buttonStyle(.plain)
.padding(.leading, Spacing.xs)
}
}
}
private var capsuleBackground: Color {
sessionIsLive
? palette.accentMuted.opacity(0.55)
: palette.surface.opacity(0.88)
.fixedSize(horizontal: true, vertical: false)
.animation(Motion.soft, value: flowManager.isActive)
}
// MARK: - Flow extras (warnings / hints)
@@ -333,12 +320,12 @@ struct HomeView: View {
.tint(palette.accent)
.focused($previewFocused)
.lineLimit(1...100)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(maxWidth: .infinity, minHeight: 180, maxHeight: .infinity, alignment: .topLeading)
.padding(Spacing.md)
.background(palette.surfaceMuted, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.background(palette.surfaceElevated, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(previewFocused ? palette.dividerStrong : palette.divider, lineWidth: 0.5)
.stroke(previewFocused ? palette.dividerStrong : palette.dividerStrong.opacity(0.75), lineWidth: 1)
)
// TextField only hit-tests the text line(s); expand taps to the full card.
.contentShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
@@ -357,7 +344,7 @@ struct HomeView: View {
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.fixedSize(horizontal: false, vertical: true)
}
}
+20 -44
View File
@@ -4,17 +4,11 @@
// "Local engine" block for the settings card.
//
// v0.2.0:
// - The on-device ASR engine is fixed at iOS 26 `SpeechAnalyzer` +
// `DictationTranscriber`. The previous picker (SpeechAnalyzer vs
// Qwen3 CoreML) and the `ModelListActionButton` row are gone with
// the Qwen3 backend there is nothing for the user to download.
// - The "Cloud polish after ASR" toggle replaces that surface. When
// enabled, the transcript produced by the local engine is routed
// through the user's configured LLM (DeepSeek by default) before
// insertion. When disabled, the local engine is pure on-device ASR.
// - The toggle warns the user that enabling it sends text to a cloud
// API and gates on a non-empty Keychain the Keychain-write UI
// lives in `APISettingsCard` (cloud engine shares the same field).
// - On-device ASR is fixed at iOS 26 `SpeechAnalyzer` +
// `DictationTranscriber` (nothing to download).
// - Post-ASR polish is always on via the built-in DeepSeek path
// (`PreconfiguredKeys.local.swift`, gitignored). The user never
// pastes a key for local mode.
import SwiftUI
import OSGKeyboardShared
@@ -27,20 +21,10 @@ struct LocalModelsGroup: View {
@ObservedObject var config: ProviderConfig
var body: some View {
// v0.2.1 follow-up: the LocalEngineGroup now owns the
// translation row so the local-engine Settings tab reads as
// one cohesive card. The same surface chrome
// (`palette.surface` + rounded border) the cloud branch uses
// on its own card wraps the whole group so it sits flush with
// the language tab above.
VStack(spacing: 0) {
speechRow
Divider().background(palette.divider)
cloudPolishRow
if config.isTranslationRowVisible {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: true)
}
polishRow
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
@@ -51,53 +35,45 @@ struct LocalModelsGroup: View {
// MARK: Speech row
/// Single-line summary that surfaces the only on-device ASR engine
/// in v0.2.0 (iOS 26 `SpeechAnalyzer`) with a "built-in" badge so
/// the user sees there is nothing to download.
private var speechRow: some View {
HStack(spacing: Spacing.xs) {
Text("settings.localModels.speechRole")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.xs)
builtInBadge
engineBadge("settings.localModels.speechEngine")
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
// MARK: Cloud polish toggle
// MARK: Polish row
/// Switch that turns on the post-ASR cloud-polish step. The toggle
/// itself is always live (the user can flip it without having a
/// key yet), but the polish call short-circuits with an Alert if
/// the Keychain is empty when it fires.
///
/// v0.2.1 follow-up: dropped the inline "uses DeepSeek" caption
/// (the user already opted into cloud mode by switching engines,
/// and the vendor name surfaces when they tap the row's helper
/// text in onboarding / deep links). Title + switch is enough.
private var cloudPolishRow: some View {
Toggle(isOn: $config.localModeCloudPolishEnabled) {
Text("settings.localModels.cloudPolish.title")
/// Built-in post-ASR polish for the local engine. No API key UI
/// the vendor key is supplied at build time only.
private var polishRow: some View {
HStack(spacing: Spacing.xs) {
Text("settings.localModels.polishRole")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
Spacer(minLength: Spacing.xs)
engineBadge("settings.localModels.polishEngine")
}
.toggleStyle(.switch)
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
// MARK: Helpers
private var builtInBadge: some View {
/// Accent badge naming the engine that backs each local-mode row
/// (e.g. "Apple iOS Speech" for ASR, "OSGKeyboard " for polish).
private func engineBadge(_ labelKey: LocalizedStringKey) -> some View {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 12, weight: .semibold))
Text("settings.localModels.builtIn")
Text(labelKey)
.font(TypeStyle.caption)
}
.foregroundStyle(palette.accent)
}
}
}
+19 -2
View File
@@ -9,6 +9,7 @@ struct MainTabView: View {
@EnvironmentObject private var flowManager: FlowSessionManager
@State private var tab: AppTab = .keyboard
@State private var isTabBarHidden = false
var body: some View {
ZStack(alignment: .bottom) {
@@ -20,17 +21,33 @@ struct MainTabView: View {
HomeView()
case .history:
HistoryView()
case .dictionary:
PersonalDictionaryView()
case .settings:
SettingsView(presentation: .tab)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.environment(\.isTabBarVisible, !isTabBarHidden)
.safeAreaInset(edge: .bottom, spacing: 0) {
Color.clear.frame(height: 88)
if !isTabBarHidden {
Color.clear.frame(height: 88)
}
}
.onPreferenceChange(TabBarHiddenPreferenceKey.self) { hidden in
withAnimation(Motion.quick) {
isTabBarHidden = hidden
}
}
MinimalTabBar(selection: $tab)
if !isTabBarHidden {
MinimalTabBar(selection: $tab)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
// Keep home card/input/tab layout fixed when system keyboard appears.
// Let the keyboard overlay the content instead of pushing it.
.ignoresSafeArea(.keyboard, edges: .bottom)
.onAppear { flowManager.autoStartIfNeeded() }
}
}
+3 -20
View File
@@ -735,26 +735,9 @@ private struct APISetupPage: View {
APISettingsCard(config: config)
.padding(.horizontal, Spacing.lg)
} else {
// v0.2.0: local engine is iOS `SpeechAnalyzer` only.
// Surface the cloud-polish toggle and a one-line
// reminder that the iOS ASR is bundled with iOS 26
// (no download step).
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("onboarding.api.localModels.hint")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
LocalModelsGroup(config: config)
.background(
palette.surface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
.padding(.horizontal, Spacing.lg)
// Local engine: built-in ASR + built-in polish (no API card).
LocalModelsGroup(config: config)
.padding(.horizontal, Spacing.lg)
}
}
@@ -40,12 +40,13 @@ struct OpenSourceLicensesView: View {
.stroke(palette.divider, lineWidth: 0.5)
)
}
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.licenses.title")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
private func licenseRow(_ entry: OpenSourceLicenseCatalog.Entry) -> some View {
@@ -105,11 +106,12 @@ private struct OpenSourceLicenseDetailView: View {
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(entry.name)
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
}
@@ -0,0 +1,85 @@
// PersonalDictionaryEntrySheet.swift
// OSGKeyboard · Main App
//
// Minimal add / edit sheet for a single personal-dictionary term.
import SwiftUI
import OSGKeyboardShared
struct PersonalDictionaryEntrySheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette: ThemePalette
let initialTerm: String
let isEditing: Bool
let onSave: (String) -> Void
@State private var term: String = ""
@FocusState private var termFocused: Bool
/// + + `.medium`
private static let sheetHeight: CGFloat = 208
var body: some View {
NavigationStack {
VStack(alignment: .leading, spacing: Spacing.sm) {
TextField("settings.personalDictionary.add.field", text: $term)
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.tint(palette.accent)
.focused($termFocused)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
.stroke(termFocused ? palette.accent : palette.divider, lineWidth: termFocused ? 1 : 0.5)
)
Text("settings.personalDictionary.add.footer")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.horizontal, Spacing.lg)
.padding(.top, Spacing.xs)
.padding(.bottom, Spacing.md)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(palette.background.ignoresSafeArea())
.navigationTitle(
isEditing
? "settings.personalDictionary.edit.title"
: "settings.personalDictionary.add.title"
)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("common.cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("common.save") { save() }
.disabled(trimmedTerm.isEmpty)
.tint(palette.accent)
}
}
.onAppear {
term = initialTerm
termFocused = true
}
}
.presentationDetents([.height(Self.sheetHeight)])
.presentationDragIndicator(.visible)
}
private var trimmedTerm: String {
term.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func save() {
guard !trimmedTerm.isEmpty else { return }
onSave(trimmedTerm)
dismiss()
}
}
+154 -98
View File
@@ -1,22 +1,10 @@
// PersonalDictionaryView.swift
// OSGKeyboard · Main App
//
// Settings Personal Dictionary: review, search, delete individual
// entries, or clear the whole dictionary. Reads / writes the
// Personal Dictionary tab: review, search, add, edit, 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
@@ -29,47 +17,68 @@ struct PersonalDictionaryView: View {
@State private var dictionary: PersonalDictionary = AppGroupStore().personalDictionary
@State private var searchText: String = ""
@State private var showClearAllConfirmation = false
@State private var showEntrySheet = false
@State private var editingEntry: PersonalDictionary.Entry?
@State private var generatingAliasEntryIDs: Set<UUID> = []
private let store = AppGroupStore()
private let aliasGenerator = DictionaryAliasGenerator()
var body: some View {
Group {
if dictionary.entries.isEmpty {
emptyState
} else {
list
NavigationStack {
ZStack {
palette.background.ignoresSafeArea()
if dictionary.entries.isEmpty {
emptyState
} else {
list
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(palette.surface.ignoresSafeArea())
.navigationTitle("settings.personalDictionary.title")
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
.toolbar {
if !dictionary.entries.isEmpty {
.background(palette.background)
.navigationTitle("settings.personalDictionary.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
if !dictionary.entries.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
Button {
showClearAllConfirmation = true
} label: {
Image(systemName: "trash")
}
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
.confirmationDialog(
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
isPresented: $showClearAllConfirmation,
titleVisibility: .visible
) {
Button(AppL10n.string("settings.personalDictionary.clearAll.confirm"), role: .destructive) {
clearAll()
}
Button(AppL10n.string("common.cancel"), role: .cancel) {}
} message: {
Text("settings.personalDictionary.clearAll.message")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showClearAllConfirmation = true
editingEntry = nil
showEntrySheet = true
} label: {
Image(systemName: "trash")
Image(systemName: "plus")
}
.accessibilityLabel(AppL10n.string("settings.personalDictionary.clearAll"))
.accessibilityLabel(AppL10n.string("settings.personalDictionary.add.title"))
}
}
}
.toolbarBackground(palette.surface, for: .navigationBar)
.toolbarBackground(.visible, for: .navigationBar)
.confirmationDialog(
AppL10n.string("settings.personalDictionary.clearAll.confirmTitle"),
isPresented: $showClearAllConfirmation,
titleVisibility: .visible
) {
Button(AppL10n.string("settings.personalDictionary.clearAll.confirm"), role: .destructive) {
clearAll()
.sheet(isPresented: $showEntrySheet) {
PersonalDictionaryEntrySheet(
initialTerm: editingEntry?.term ?? "",
isEditing: editingEntry != nil
) { term in
saveManualEntry(term: term, editingID: editingEntry?.id)
}
Button(AppL10n.string("common.cancel"), role: .cancel) {}
} message: {
Text("settings.personalDictionary.clearAll.message")
}
}
@@ -83,41 +92,34 @@ struct PersonalDictionaryView: View {
section(for: category, items: items)
}
}
.padding(.horizontal, Spacing.md)
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.padding(.bottom, 100)
.tabBarScrollBottomPadding()
}
.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.caption)
.foregroundStyle(palette.textPrimary)
Text("settings.personalDictionary.intro.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
}
Spacer()
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("settings.personalDictionary.intro.title")
.font(TypeStyle.caption)
.foregroundStyle(palette.textPrimary)
Text("settings.personalDictionary.intro.body")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(Spacing.md)
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
private func section(for category: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
private func section(for _: PersonalDictionary.Entry.Category, items: [PersonalDictionary.Entry]) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text(SharedL10n.string(category.labelKey, language: config.uiLanguage))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary)
.textCase(.uppercase)
VStack(spacing: 0) {
ForEach(Array(items.enumerated()), id: \.element.id) { index, entry in
entryRow(entry)
@@ -126,49 +128,65 @@ struct PersonalDictionaryView: View {
}
}
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
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(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
if entry.usageCount > 1 {
Text("·")
Button {
editingEntry = entry
showEntrySheet = true
} label: {
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(SharedL10n.string(entry.source.labelKey, language: config.uiLanguage))
.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)
if entry.usageCount > 1 {
Text("·")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Text("settings.personalDictionary.usageCount \(entry.usageCount)")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
}
if generatingAliasEntryIDs.contains(entry.id) {
Text("·")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
Text("settings.personalDictionary.aliases.generating")
.font(TypeStyle.caption2)
.foregroundStyle(palette.textTertiary)
} else 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()
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(palette.textTertiary)
}
Spacer()
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
}
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
.contentShape(Rectangle())
.buttonStyle(.plain)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
delete(entry)
@@ -181,7 +199,8 @@ struct PersonalDictionaryView: View {
private var emptyState: some View {
VStack(spacing: Spacing.sm) {
Spacer()
MaterialIcon(name: .menuBook, size: 36)
Image(systemName: "square.stack.3d.down.right.fill")
.font(.system(size: 36, weight: .regular))
.foregroundStyle(palette.textTertiary.opacity(0.5))
Text("settings.personalDictionary.empty.title")
.font(TypeStyle.body)
@@ -191,6 +210,15 @@ struct PersonalDictionaryView: View {
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, Spacing.xl)
Button {
editingEntry = nil
showEntrySheet = true
} label: {
Text("settings.personalDictionary.add.title")
}
.buttonStyle(.borderedProminent)
.tint(palette.accent)
.padding(.top, Spacing.sm)
Spacer()
}
}
@@ -209,7 +237,6 @@ struct PersonalDictionaryView: View {
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 }
@@ -223,13 +250,44 @@ struct PersonalDictionaryView: View {
// MARK: - Mutations
private func saveManualEntry(term: String, editingID: UUID?) {
let previousTerm = editingID.flatMap { id in
dictionary.entries.first(where: { $0.id == id })?.term
}
let termChanged = previousTerm.map {
$0.caseInsensitiveCompare(term) != .orderedSame
} ?? true
guard dictionary.upsertManual(term: term, existingID: editingID) != nil else { return }
persist()
guard let saved = dictionary.entry(matchingTerm: term) else { return }
let shouldGenerate = saved.source == .manual && (editingID == nil || termChanged)
if shouldGenerate {
generateAliases(for: saved.id, term: saved.term)
}
}
private func generateAliases(for entryID: UUID, term: String) {
generatingAliasEntryIDs.insert(entryID)
Task {
let aliases = await aliasGenerator.generateAliases(for: term)
generatingAliasEntryIDs.remove(entryID)
guard !aliases.isEmpty else { return }
dictionary.updateAliases(for: entryID, aliases: aliases)
persist()
}
}
private func delete(_ entry: PersonalDictionary.Entry) {
dictionary.entries.removeAll { $0.id == entry.id }
generatingAliasEntryIDs.remove(entry.id)
persist()
}
private func clearAll() {
dictionary = .empty
generatingAliasEntryIDs = []
persist()
}
@@ -242,9 +300,7 @@ struct PersonalDictionaryView: View {
#if DEBUG
#Preview {
ThemedRoot {
NavigationStack {
PersonalDictionaryView()
}
PersonalDictionaryView()
}
}
#endif
@@ -18,6 +18,7 @@ struct PrivacyPolicyView: View {
.background(palette.background.ignoresSafeArea())
.navigationTitle("settings.privacy.policy")
.navigationBarTitleDisplayMode(.inline)
.hidesTabBarWhenPushed()
}
private var privacyScrollAnchor: String? {
@@ -11,10 +11,8 @@ struct ProviderPickerSection: View {
var body: some View {
// v0.2.1 follow-up: filter out presets marked as
// `isUserSelectable == false` so a future "DeepSeek key
// pre-fill" preset (or similar) can ship in `presets` without
// showing up in the picker.
let visiblePresets = LLMProvider.presets.filter { $0.isUserSelectable }
// `isUserSelectable == false` (DeepSeek is local-engine only).
let visiblePresets = LLMProvider.userSelectablePresets
VStack(spacing: 0) {
ForEach(Array(visiblePresets.enumerated()), id: \.element.id) { index, provider in
Button {
+146 -124
View File
@@ -28,94 +28,79 @@ struct SettingsView: View {
// Dynamic locale list loaded from SFSpeechRecognizer on first appear.
@State private var dynamicLocales: [(id: String, onDevice: Bool)] = []
@State private var showResetConfirmation = false
// v0.2.0: no on-device model manager / pending download state
// iOS `SpeechAnalyzer` ships with iOS 26 and needs nothing
// downloaded.
var body: some View {
NavigationStack {
VStack(spacing: 0) {
PageHeaderRow(title: "settings.title") {
HStack(spacing: Spacing.xs) {
PageHeaderConfirmButton(
systemImage: "arrow.counterclockwise",
accessibilityLabel: "settings.reset.confirm",
confirmTitle: "settings.reset.title",
confirmMessage: "settings.reset.message",
confirmActionTitle: "common.reset"
) {
config.reset()
SpeechHistoryStore.shared.clearAll()
ZStack {
palette.background.ignoresSafeArea()
ScrollView {
VStack(spacing: Spacing.md) {
languageAndPolishSection
engineSection
// v0.2.1: hide provider/api card when the
// local engine is active regardless of the
// cloud-polish toggle. Local mode is
// contractually ASR-only, so provider/model/
// base URL/API key controls have no use
// and exposing them invites the user to fill
// out a DeepSeek key they can't use.
if config.engineMode == "cloud" {
providerSection
apiSection
}
if presentation == .sheet {
Button("common.done") { dismiss() }
.font(TypeStyle.headline)
.foregroundStyle(palette.accent)
.frame(minHeight: 44)
if config.engineMode == "local" {
localEngineSettingsSection
}
if presentation == .tab {
footerLinks
}
}
}
ZStack {
palette.background.ignoresSafeArea()
ScrollView {
VStack(spacing: Spacing.md) {
appLanguageSection
engineSection
languageAndPolishSection
// v0.2.1: hide provider/api card when the
// local engine is active regardless of the
// cloud-polish toggle. Local mode is
// contractually ASR-only, so provider/model/
// base URL/API key controls have no use
// and exposing them invites the user to fill
// out a DeepSeek key they can't use.
if config.engineMode == "cloud" {
providerSection
apiSection
}
if config.engineMode == "local" {
localEngineSettingsSection
}
if presentation == .tab {
preferencesSection
footerLinks
}
}
.padding(.horizontal, Spacing.md)
.padding(.vertical, Spacing.md)
.padding(.bottom, presentation == .tab ? 100 : Spacing.lg)
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
.modifier(SettingsScrollBottomPadding(presentation: presentation))
}
}
.background(palette.background)
.toolbar(.hidden, for: .navigationBar)
.navigationTitle("settings.title")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
showResetConfirmation = true
} label: {
Image(systemName: "arrow.counterclockwise")
}
.accessibilityLabel("settings.reset.confirm")
.confirmationDialog(
"settings.reset.title",
isPresented: $showResetConfirmation,
titleVisibility: .visible
) {
Button("common.reset", role: .destructive) {
config.reset()
SpeechHistoryStore.shared.clearAll()
}
Button("common.cancel", role: .cancel) {}
} message: {
Text("settings.reset.message")
}
}
if presentation == .sheet {
ToolbarItem(placement: .confirmationAction) {
Button("common.done") { dismiss() }
}
}
}
.task { await loadDynamicLocales() }
// v0.2.0: no on-device model manager to refresh the
// iOS ASR backend is always ready.
}
}
// MARK: - App language
private var appLanguageSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.appLanguage.title")
Picker("", selection: $config.uiLanguage) {
ForEach(AppUILanguage.allCases) { language in
Text(LocalizedStringKey(language.labelKey)).tag(language)
}
}
.pickerStyle(.segmented)
.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)
)
}
}
// MARK: - Engine
private var engineSection: some View {
@@ -126,8 +111,17 @@ struct SettingsView: View {
private var languageAndPolishSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.languageAndPolish.title")
sectionHeader("settings.preferences.title")
VStack(spacing: 0) {
AppLanguagePickerRow(
selection: Binding(
get: { config.uiLanguage },
set: { config.uiLanguage = $0 }
)
)
Divider().background(palette.divider)
LocalePickerRow(
locales: effectiveLocales,
selection: Binding(
@@ -135,14 +129,32 @@ struct SettingsView: View {
set: { config.localeId = $0 }
)
)
Divider().background(palette.divider)
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
Divider().background(palette.divider)
polishIntensityPreferenceRows
if config.isTranslationRowVisible {
Divider().background(palette.divider)
TranslationPickerRow(config: config, isVisible: true)
}
Divider().background(palette.divider)
cursorDragNavigationToggleRow
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
@@ -241,62 +253,33 @@ struct SettingsView: View {
dynamicLocales = entries
}
// MARK: - Preferences (tab settings only)
private var preferencesSection: some View {
VStack(alignment: .leading, spacing: SettingsListMetrics.sectionLabelSpacing) {
sectionHeader("settings.preferences.title")
VStack(spacing: 0) {
HandednessPickerRow(
selection: Binding(
get: { config.handednessPreference },
set: { config.handednessPreference = $0 }
)
)
Divider().background(palette.divider)
polishIntensityPreferenceRows
Divider().background(palette.divider)
NavigationLink {
PersonalDictionaryView()
} label: {
personalDictionaryPreferenceRow
}
.buttonStyle(.plain)
}
.background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
.stroke(palette.divider, lineWidth: 0.5)
)
}
}
// MARK: - Preference row helpers
private var polishIntensityPreferenceRows: some View {
VStack(alignment: .leading, spacing: Spacing.sm) {
Text("settings.polishIntensity.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
.padding(.horizontal, Spacing.md)
.padding(.top, Spacing.sm)
Picker("", selection: $config.polishIntensity) {
ForEach(PolishIntensity.allCases, id: \.self) { intensity in
Text(SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
.tag(intensity)
//
PickerRow(
title: AppL10n.string("settings.polishIntensity.title"),
options: PolishIntensity.allCases.map { intensity in
(intensity.rawValue, SharedL10n.string(intensity.labelKey, language: config.uiLanguage))
},
selection: Binding(
get: { config.polishIntensity.rawValue },
set: { newValue in
config.polishIntensity = PolishIntensity(rawValue: newValue) ?? .medium
}
}
.pickerStyle(.segmented)
.padding(.horizontal, Spacing.md)
.padding(.bottom, Spacing.sm)
}
)
)
}
private var personalDictionaryPreferenceRow: some View {
footerNavigationRow(title: "settings.personalDictionary.title")
private var cursorDragNavigationToggleRow: some View {
Toggle(isOn: $config.cursorDragNavigationEnabled) {
Text("settings.cursorDragNavigation.title")
.font(TypeStyle.body)
.foregroundStyle(palette.textPrimary)
}
.tint(palette.accent)
.padding(.horizontal, Spacing.md)
.frame(minHeight: SettingsListMetrics.singleLineMinHeight)
}
// MARK: - Footer links (tab settings only)
@@ -412,6 +395,45 @@ struct SettingsView: View {
}
}
// MARK: - Tab dock bottom padding (tab root only)
private struct SettingsScrollBottomPadding: ViewModifier {
let presentation: SettingsPresentation
func body(content: Content) -> some View {
if presentation == .tab {
content.tabBarScrollBottomPadding()
} else {
content.padding(.bottom, Spacing.lg)
}
}
}
// MARK: - App language picker row
private struct AppLanguagePickerRow: View {
@Binding var selection: AppUILanguage
private var options: [(id: String, label: String)] {
AppUILanguage.allCases.map { language in
(language.rawValue, AppL10n.string(language.labelKey))
}
}
var body: some View {
PickerRow(
title: AppL10n.string("settings.appLanguage.title"),
options: options,
selection: Binding(
get: { selection.rawValue },
set: { newValue in
selection = AppUILanguage(rawValue: newValue) ?? .auto
}
)
)
}
}
// MARK: - Handedness picker row
private struct HandednessPickerRow: View {
+15 -4
View File
@@ -29,7 +29,7 @@
"onboarding.enable.step3.suffix" = "and select OSGKeyboard";
"onboarding.enable.openSettings" = "Open Settings";
"onboarding.api.title" = "Choose Engine";
"onboarding.api.localModels.hint" = "Download the on-device CoreML ASR model below before you finish setup (only needed when using Qwen3-ASR).";
"onboarding.api.localModels.hint" = "Local engine uses built-in on-device speech recognition and built-in polish — no API key needed.";
"settings.onboarding.replay" = "Restart permission setup";
/* Common navigation */
@@ -39,6 +39,7 @@
"common.continue" = "Continue";
"common.reset" = "Reset";
"common.cancel" = "Cancel";
"common.save" = "Save";
"common.clear" = "Clear";
"common.delete" = "Delete";
"common.space" = "Space";
@@ -119,7 +120,10 @@
"settings.languageModels.title" = "Language & models";
"settings.localModels.title" = "On-device models";
"settings.localModels.speechRole" = "Speech";
"settings.localModels.polishRole" = "Polish";
"settings.localModels.builtIn" = "Built-in";
"settings.localModels.speechEngine" = "Apple iOS Speech";
"settings.localModels.polishEngine" = "OSGKeyboard Built-in";
"settings.localModels.allReady" = "Ready";
"settings.localModels.readiness %lld %lld" = "%lld/%lld ready";
"settings.localModels.cloudPolish.title" = "Cloud polish after ASR";
@@ -139,6 +143,7 @@
"settings.handedness.title" = "Handedness";
"settings.handedness.left" = "Left hand";
"settings.handedness.right" = "Right hand";
"settings.cursorDragNavigation.title" = "Drag beside mic to move cursor";
"settings.systemPrompt.reset" = "Reset";
"settings.asrLocale" = "ASR locale";
"settings.engineBadge.ios26" = "SpeechAnalyzer — always on-device";
@@ -308,6 +313,7 @@
/* Tabs */
"tab.keyboard" = "Keyboard";
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.settings" = "Settings";
/* History */
@@ -335,12 +341,17 @@
"settings.personalDictionary.sectionTitle" = "Personal dictionary";
"settings.personalDictionary.title" = "Personal dictionary";
"settings.personalDictionary.summary" = "Words the LLM must preserve verbatim when polishing.";
"settings.personalDictionary.intro.title" = "How words get added";
"settings.personalDictionary.intro.body" = "Words you dictate repeatedly are auto-learned. You can also edit or delete any entry here.";
"settings.personalDictionary.intro.title" = "About your dictionary";
"settings.personalDictionary.intro.body" = "Add words manually. Common speech-recognition mishearings are generated automatically after you save. Tap a word to edit.";
"settings.personalDictionary.empty.title" = "No words yet";
"settings.personalDictionary.empty.body" = "Words you dictate often will appear here. You can also delete any entry from this screen.";
"settings.personalDictionary.empty.body" = "Add words you want protected during speech correction.";
"settings.personalDictionary.search.prompt" = "Search words";
"settings.personalDictionary.usageCount" = "%lld uses";
"settings.personalDictionary.add.title" = "Add word";
"settings.personalDictionary.edit.title" = "Edit word";
"settings.personalDictionary.add.field" = "Word";
"settings.personalDictionary.add.footer" = "Common speech-recognition mishearings are generated automatically after you save.";
"settings.personalDictionary.aliases.generating" = "Generating aliases…";
"settings.personalDictionary.clearAll" = "Clear dictionary";
"settings.personalDictionary.clearAll.confirmTitle" = "Clear all dictionary words?";
"settings.personalDictionary.clearAll.message" = "This removes every word the LLM was protecting. This cannot be undone.";
+16 -5
View File
@@ -29,7 +29,7 @@
"onboarding.enable.step3.suffix" = ",选中 OSGKeyboard";
"onboarding.enable.openSettings" = "去设置";
"onboarding.api.title" = "选择语音转文字 AI 引擎";
"onboarding.api.localModels.hint" = "使用 Qwen3-ASR 时需先下载下方 CoreML 语音识别模型,完成后才能开始使用。";
"onboarding.api.localModels.hint" = "本地引擎使用内置语音识别与内置润色,无需填写 API Key。";
"settings.onboarding.replay" = "重新开始权限引导";
/* Common navigation */
@@ -39,6 +39,7 @@
"common.continue" = "继续";
"common.reset" = "重置";
"common.cancel" = "取消";
"common.save" = "保存";
"common.clear" = "清空";
"common.delete" = "删除";
"common.space" = "空格";
@@ -119,7 +120,10 @@
"settings.languageModels.title" = "语言与模型";
"settings.localModels.title" = "本地模型";
"settings.localModels.speechRole" = "语音识别";
"settings.localModels.polishRole" = "润色";
"settings.localModels.builtIn" = "内置";
"settings.localModels.speechEngine" = "Apple iOS Speech";
"settings.localModels.polishEngine" = "OSGKeyboard 内置";
"settings.localModels.allReady" = "已就绪";
"settings.localModels.readiness %lld %lld" = "%lld/%lld 已就绪";
"settings.localModels.cloudPolish.title" = "识别后云端润色";
@@ -139,6 +143,7 @@
"settings.handedness.title" = "握持偏好";
"settings.handedness.left" = "左手";
"settings.handedness.right" = "右手";
"settings.cursorDragNavigation.title" = "麦克风旁拖动移动光标";
"settings.systemPrompt.reset" = "重置";
"settings.asrLocale" = "识别语言";
"settings.engineBadge.ios26" = "SpeechAnalyzer — 始终端侧";
@@ -307,6 +312,7 @@
/* Tabs */
"tab.keyboard" = "键盘";
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.settings" = "设置";
/* History */
@@ -332,14 +338,19 @@
/* v0.3.0: 个性化词库 */
"settings.personalDictionary.sectionTitle" = "个性化词库";
"settings.personalDictionary.title" = "个性词库";
"settings.personalDictionary.title" = "个性词库";
"settings.personalDictionary.summary" = "AI 润色时必须原样保留的词汇。";
"settings.personalDictionary.intro.title" = "词库如何积累";
"settings.personalDictionary.intro.body" = "你反复说出的词会自动学习。你也可以在这里编辑或删除任何词条。";
"settings.personalDictionary.intro.title" = "关于词库";
"settings.personalDictionary.intro.body" = "手动添加词条;保存后会自动生成常见误识别写法。点击词条可编辑。";
"settings.personalDictionary.empty.title" = "还没有词条";
"settings.personalDictionary.empty.body" = "你反复说出的词会出现在这里。你也可以在此页删除任何词条。";
"settings.personalDictionary.empty.body" = "添加希望在语音纠错时保护的词汇。";
"settings.personalDictionary.search.prompt" = "搜索词条";
"settings.personalDictionary.usageCount" = "使用 %lld 次";
"settings.personalDictionary.add.title" = "添加词条";
"settings.personalDictionary.edit.title" = "编辑词条";
"settings.personalDictionary.add.field" = "词条";
"settings.personalDictionary.add.footer" = "保存后会自动用 DeepSeek 生成常见误识别写法,用于 ASR 纠错。";
"settings.personalDictionary.aliases.generating" = "正在生成别名…";
"settings.personalDictionary.clearAll" = "清空词库";
"settings.personalDictionary.clearAll.confirmTitle" = "清空全部词条?";
"settings.personalDictionary.clearAll.message" = "这会移除所有受保护的词汇,且无法撤销。";