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
+8
View File
@@ -45,6 +45,7 @@ fastlane/test_output
# Local config (API keys, signing, etc.)
*.local
Signing.local.xcconfig
PreconfiguredKeys.local.swift
.env
.env.*
@@ -53,3 +54,10 @@ Signing.local.xcconfig
# Local DerivedData (when using -derivedDataPath in repo)
.derivedData/
# Lexicon build cache (downloaded SogouPopularDict TSV)
.cache/
# Python bytecode
__pycache__/
*.pyc
+104
View File
@@ -1,5 +1,109 @@
# AGENTS.md
## Versioning and releases
OSGKeyboard uses **Conventional Commits** as the single source of truth for version bumps
and `CHANGELOG.md` entries. Agents must follow this section whenever cutting a release or
writing commit messages that will ship to users.
### Version format (0.x stage)
While `MARKETING_VERSION` is `0.x.y`, treat the project as **pre-1.0**:
| Field | File | Rule |
|-------|------|------|
| Marketing version | `project.yml``MARKETING_VERSION` | `0.MINOR.PATCH` (SemVer) |
| Build number | `project.yml``CURRENT_PROJECT_VERSION` | Monotonic integer; **+1 on every release cut**, never decrease |
**Bump rules** (evaluate all commits since the last tagged/released version; take the **highest** bump):
| Commit prefix | Version bump | Example |
|---------------|--------------|---------|
| `feat:` | **MINOR** + reset PATCH → `0` | `0.3.6``0.4.0` |
| `fix:`, `perf:` (user-visible) | **PATCH** | `0.3.6``0.3.7` |
| `feat!:` or footer `BREAKING CHANGE:` | **MINOR** (pre-1.0; reserve `1.0.0` for a deliberate GA) | `0.3.6``0.4.0` |
| `refactor:`, `style:`, `docs:`, `test:`, `chore:`, `ci:` | **no bump by itself** | group with user-facing commits or skip release |
Pragmatic overrides (experienced-maintainer judgment, still objective):
- A release that is **only** internal/tooling (`chore`, `ci`, lexicon scripts with no app wiring) → **do not cut** a user-facing version; keep `[Unreleased]` in the changelog.
- A release that mixes `feat` + `fix` → bump **MINOR** (the `feat` wins).
- Security fixes that change behavior (`fix(security):`) → **PATCH** minimum; bump **MINOR** if users must change setup (e.g. new local key file).
- Whitespace-only or comment-only diffs → no release entry.
### Conventional Commit format
```
<type>(<optional scope>): <imperative summary>
[optional body]
[optional footer: BREAKING CHANGE: ...]
```
Allowed types: `feat`, `fix`, `perf`, `refactor`, `style`, `docs`, `test`, `chore`, `ci`.
Examples:
```
feat(keyboard): add cursor drag pad for precise caret movement
fix(home): use View-backed gradient on stats card
chore(lexicon): add offline SFCustomLanguageModelData export scripts
```
### Changelog (`CHANGELOG.md`)
- Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Bilingual**: every bullet is **English first**, then ` / `, then **简体中文**.
- Sections per release: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
- Workflow:
1. During development, add bullets under `## [Unreleased]` (bilingual).
2. On release cut, rename `[Unreleased]``[X.Y.Z] - YYYY-MM-DD`, insert a fresh empty `[Unreleased]` above it.
3. Derive section headings and bullets from Conventional Commits in the release range.
**Bullet template:**
```markdown
### Added
- **Short title**: English sentence. / **简短标题**:中文句子。
```
**Example entry:**
```markdown
## [0.4.0] - 2026-07-06
### Added
- **Cursor navigation**: drag pad on the keyboard for precise caret movement. / **光标导航**:键盘拖动手势区,精确移动光标。
### Fixed
- **API key handling**: move DeepSeek key into gitignored local file. / **API 密钥**:将 DeepSeek 密钥移至 gitignore 的本地文件。
```
### Release checklist (agent)
When the user asks to release or bump version:
1. `git log` from last release tag/commit → classify commits → pick bump level.
2. Update `CHANGELOG.md` (`[Unreleased]``[X.Y.Z] - date`, bilingual bullets).
3. Update `project.yml`:
- `MARKETING_VERSION` → new `0.x.y`
- `CURRENT_PROJECT_VERSION` → previous build **+ 1**
4. Commit: `chore(release): bump version to X.Y.Z (build N)` — or include in the release PR.
5. Do **not** bump version for work that stays on a feature branch until it merges to `main`.
### Single source of truth
| What | Where |
|------|--------|
| Version numbers | `project.yml` (`MARKETING_VERSION`, `CURRENT_PROJECT_VERSION`) |
| Human-readable history | `CHANGELOG.md` |
| Machine-readable history | `git log` with Conventional Commit prefixes |
`Info.plist` files reference `$(MARKETING_VERSION)` / `$(CURRENT_PROJECT_VERSION)` — do not hardcode versions in plists.
---
## Cursor Cloud specific instructions
### Platform reality: this is an iOS-only project on a Linux VM
+31 -2
View File
@@ -8,10 +8,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Polish scenarios**: pick a writing context (Daily Chat, Social Network / 小红书, Instagram / 微博, Goofy, Work, Document, TODO, Custom) in Settings, onboarding, and the keyboard top-bar `ScenarioChip`. Presets drive `ScenarioPrompt`; Custom reuses the system prompt editor.
- **Cursor navigation**: keyboard drag pad (`CursorDragPad` / `CursorNavigation`) for precise caret movement. / **光标导航**:键盘拖动手势区(`CursorDragPad` / `CursorNavigation`),精确移动光标。
- **Key sound feedback**: `KeyboardSoundFeedback` plays system key clicks on input. / **按键音反馈**`KeyboardSoundFeedback` 在输入时播放系统按键音。
- **Personal dictionary tooling**: `DictionaryAliasGenerator` and `PersonalDictionaryEntrySheet` for managing custom terms and aliases. / **个人词库工具**`DictionaryAliasGenerator``PersonalDictionaryEntrySheet`,用于管理自定义词条与别名。
- **Transcript post-processing**: `TranscriptPostProcessor` quality gate on the shared ASR path. / **转写后处理**:共享 ASR 链路上的 `TranscriptPostProcessor` 质量校验。
### Changed
- **Scenario output formats**: shared `ScenarioStyleDirective` enforces structural rules (Work → mandatory bullets for multi-item input, TODO → checklist). The same directive applies to translate-and-polish via `TranslationPrompt`.
- **Tab bar visibility**: `TabBarVisibility` centralizes show/hide handling; retired `PageHeaderRow` / `PageHeaderConfirmButton`. / **标签栏可见性**`TabBarVisibility` 统一管理显隐;移除 `PageHeaderRow` / `PageHeaderConfirmButton`
### Removed
- **DictionaryLearner**: replaced by the new dictionary tooling. / **DictionaryLearner**:由新的词库工具取代。
### Security
- **DeepSeek key handling**: move the hardcoded API key out of `PreconfiguredKeys.swift` into a gitignored `PreconfiguredKeys.local.swift` (seeded from `.example` by `generate-xcodeproj.sh`). / **DeepSeek 密钥处理**:将硬编码 API 密钥移出 `PreconfiguredKeys.swift`,改为 gitignore 的 `PreconfiguredKeys.local.swift`(由 `generate-xcodeproj.sh``.example` 生成)。
## [0.3.6] - 2026-07-05
### Changed
- **ASR polish pipeline**: global output contract (no new emojis, punctuation, structure at every intensity), `TranscriptPostProcessor` quality gate, ultra-short utterances skip LLM, removed Off polish tier (legacy `off` migrates to Medium), preceding-text context in keyboard polish path.
## [0.3.4] - 2026-07-04
### Added
- **Home usage statistics**: new home-screen stats card showing cumulative dictation time, dictation characters, translation characters, and personal-dictionary entry count.
### Changed
- **Local engine polish path**: local mode now uses the built-in DeepSeek polish path by default, removing the separate "Cloud polish after ASR" toggle and user-facing DeepSeek API key setup.
- **Provider API keys**: cloud-provider API keys are isolated per provider in Keychain, so switching providers no longer reuses the previous vendor's key.
- **Translation availability**: translation settings are visible for both local and cloud engines.
- **DeepSeek provider visibility**: DeepSeek is reserved for the local engine's built-in path and is no longer shown as a cloud-provider picker option.
### Fixed
- **Home stats rendering**: the stats card gradient background now uses a View-backed background compatible with SwiftUI's type system.
- **Usage statistics imports**: the usage statistics store now imports the shared module required for translation-state checks.
## [0.3.0] - 2026-06-24
@@ -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" = "这会移除所有受保护的词汇,且无法撤销。";
+208 -15
View File
@@ -20,6 +20,26 @@
import UIKit
import SwiftUI
import OSGKeyboardShared
import os
/// Unified log for the keyboard extension. Visible in Console.app when
/// filtered by `subsystem: com.osgkeyboard.ios`. Note: plain `print`
/// from an extension process does NOT reliably reach Xcode's console
/// (the debugger is usually attached to the host app, not the
/// extension), which is why extension-side diagnostics must go through
/// `os.Logger` to be observable.
private let keyboardExtLog = Logger(subsystem: "com.osgkeyboard.ios", category: "KeyboardExt")
private final class KeyboardHostingController: UIHostingController<KeyboardRootView> {
override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.left, .right]
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
}
}
@objc(KeyboardViewController)
@MainActor
@@ -59,7 +79,9 @@ public final class KeyboardViewController: UIInputViewController {
private let polisher = PolishingService()
private let persistor = AppGroupPersistor()
private var hosting: UIHostingController<KeyboardRootView>!
private var hosting: UIHostingController<KeyboardRootView>?
/// Centred "" hint, stacked above the SwiftUI tree.
private var cursorDragHintLabel: UILabel?
/// Legacy one-shot handoff (`osgkeyboard://dictate`).
private var awaitingDictationResult = false
private var dictationRequestStartedAt: TimeInterval = 0
@@ -75,6 +97,14 @@ public final class KeyboardViewController: UIInputViewController {
private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
private var configDarwinObserver: FlowSessionDarwinObserver?
/// Serializes caret moves so `textDocumentProxy` keeps up with drag events.
private var pendingHorizontalCursorSteps = 0
private var pendingVerticalCursorSteps = 0
private var cursorMoveFlushScheduled = false
/// Fires once per vertical chunk step during a cursor drag.
private let cursorLineHaptic = UIImpactFeedbackGenerator(style: .light)
/// Characters moved per vertical drag step (up = back, down = forward).
private static let cursorVerticalChunkSize = 20
/// Grace period after a chip-side translation write during which the
/// 1 Hz App Group poll must not overwrite `translationTargetLocaleId`.
private var translationConfigProtectedUntil: Date?
@@ -94,6 +124,8 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidLoad() {
super.viewDidLoad()
keyboardExtLog.info("viewDidLoad — extension booted (build marker: cursor-drag diag)")
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
installKeyboardHeight()
configureDictationBehavior()
installStateActions()
@@ -120,6 +152,7 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
configureDictationBehavior()
KeyboardSetupBridge.markExtensionAppearance(hasFullAccess: hasFullAccess)
consumePendingDictationResultIfNeeded()
@@ -131,6 +164,7 @@ public final class KeyboardViewController: UIInputViewController {
// from Settings.app or the host app, and the App Group is the
// only thing both processes see consistently.
syncOnboardingStateFromAppGroup()
refreshConfigFromAppGroup()
// Auto-advance past step 3 ("Enable Keyboard") if the user has
// enabled the keyboard in Settings.app while we were away.
// This is the "automatic return from jump" feature: no manual
@@ -145,10 +179,19 @@ public final class KeyboardViewController: UIInputViewController {
public override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
disableSystemGestureDelays()
// Presentation finished lock to the true content-driven height.
keyboardHeightConstraint?.constant = targetKeyboardHeight
}
public override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
[.left, .right]
}
public override var childForScreenEdgesDeferringSystemGestures: UIViewController? {
hosting
}
public override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
cancelPipeline()
@@ -168,6 +211,37 @@ public final class KeyboardViewController: UIInputViewController {
hasDictationKey = true
}
/// Keyboard extensions can lose or delay touches near the screen
/// edges because system edge-pan recognizers get first refusal.
/// Deferring edges above is the intent; this sweep removes delay
/// flags from recognizers already attached to the host hierarchy.
private func disableSystemGestureDelays() {
disableGestureDelays(in: view)
var parent = view.superview
while let current = parent {
disableGestureDelays(in: current)
parent = current.superview
}
if let window = view.window {
disableGestureDelays(in: window)
if let rootView = window.rootViewController?.view {
disableGestureDelays(in: rootView)
}
}
}
private func disableGestureDelays(in targetView: UIView) {
targetView.gestureRecognizers?.forEach { recognizer in
recognizer.delaysTouchesBegan = false
recognizer.delaysTouchesEnded = false
recognizer.cancelsTouchesInView = false
if recognizer is UIScreenEdgePanGestureRecognizer {
recognizer.isEnabled = false
}
}
targetView.subviews.forEach(disableGestureDelays)
}
// MARK: - Wiring
private func installStateActions() {
@@ -194,6 +268,93 @@ public final class KeyboardViewController: UIInputViewController {
state.insertNewline = { [weak self] in self?.textDocumentProxy.insertText("\n") }
state.insertSpace = { [weak self] in self?.textDocumentProxy.insertText(" ") }
state.deleteBackward = { [weak self] in self?.textDocumentProxy.deleteBackward() }
state.moveCursorHorizontal = { [weak self] steps in
self?.moveCursorHorizontally(by: steps)
}
state.moveCursorVertical = { [weak self] steps in
self?.moveCursorVertically(by: steps)
}
state.setCursorDragActive = { [weak self] active in
self?.setCursorDragActive(active)
}
}
private func setCursorDragActive(_ active: Bool) {
state.cursorDragActive = active
updateCursorDragWash(active: active)
}
private func updateCursorDragWash(active: Bool) {
if active {
cursorLineHaptic.prepare()
}
layoutCursorDragChrome()
// Gradient wash intentionally not shown only the centred hint.
guard let hint = cursorDragHintLabel else { return }
if active {
hint.isHidden = false
UIView.animate(withDuration: 0.12) { hint.alpha = 1 }
} else {
UIView.animate(withDuration: 0.12, animations: { hint.alpha = 0 }) { [weak self] _ in
guard let self, !self.state.cursorDragActive else { return }
hint.isHidden = true
}
}
}
private func moveCursorHorizontally(by steps: Int) {
guard steps != 0 else { return }
pendingHorizontalCursorSteps += steps
scheduleCursorMoveFlush()
}
private func moveCursorVertically(by steps: Int) {
guard steps != 0 else { return }
pendingVerticalCursorSteps += steps
scheduleCursorMoveFlush()
}
private func scheduleCursorMoveFlush() {
guard !cursorMoveFlushScheduled else { return }
cursorMoveFlushScheduled = true
// Give the text-document proxy one run-loop turn between drag
// samples so caret updates are not dropped. Runs on the main
// queue (not a Task) to avoid `unsafeForcedSync` proxy access.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.012) { [weak self] in
guard let self else { return }
self.cursorMoveFlushScheduled = false
let horizontal = self.pendingHorizontalCursorSteps
let vertical = self.pendingVerticalCursorSteps
self.pendingHorizontalCursorSteps = 0
self.pendingVerticalCursorSteps = 0
if horizontal != 0 {
keyboardExtLog.info("adjustTextPosition h=\(horizontal)")
self.textDocumentProxy.adjustTextPosition(byCharacterOffset: horizontal)
}
if vertical != 0 {
self.applyVerticalCursorSteps(vertical)
}
if self.pendingHorizontalCursorSteps != 0 || self.pendingVerticalCursorSteps != 0 {
self.scheduleCursorMoveFlush()
}
}
}
private func applyVerticalCursorSteps(_ steps: Int) {
let direction = steps > 0 ? 1 : -1
var remaining = abs(steps)
let chunk = Self.cursorVerticalChunkSize
while remaining > 0 {
textDocumentProxy.adjustTextPosition(byCharacterOffset: direction * chunk)
cursorLineHaptic.impactOccurred()
cursorLineHaptic.prepare()
remaining -= 1
}
}
// MARK: - Onboarding persistence (v0.3.0)
@@ -271,7 +432,7 @@ public final class KeyboardViewController: UIInputViewController {
private func installSwiftUI() {
let root = KeyboardRootView(state: state)
let host = UIHostingController(rootView: root)
let host = KeyboardHostingController(rootView: root)
host.view.backgroundColor = .clear
host.view.translatesAutoresizingMaskIntoConstraints = false
host.view.clipsToBounds = false
@@ -289,12 +450,42 @@ public final class KeyboardViewController: UIInputViewController {
])
host.didMove(toParent: self)
self.hosting = host
// Cursor-drag chrome: only a centred hint label above the SwiftUI
// tree (non-interactive so the pads underneath still receive
// touches). The green gradient wash was removed it was too hard to
// align cleanly with the system keyboard's rounded top edge.
let hint = UILabel()
hint.text = ExtL10n.string("keyboard.cursorDrag.centerHint")
hint.font = .systemFont(ofSize: 22, weight: .medium)
hint.textColor = UIColor.label.withAlphaComponent(0.10)
hint.textAlignment = .center
hint.numberOfLines = 1
hint.adjustsFontSizeToFitWidth = true
hint.minimumScaleFactor = 0.7
hint.isUserInteractionEnabled = false
hint.isHidden = true
hint.alpha = 0
view.addSubview(hint)
self.cursorDragHintLabel = hint
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
layoutCursorDragChrome()
}
private func layoutCursorDragChrome() {
cursorDragHintLabel?.frame = view.bounds
}
private func loadPersistedConfig() {
switch persistor.load(into: state) {
case .loaded:
break
keyboardExtLog.info(
"config loaded — cursorDragNavigationEnabled=\(self.state.cursorDragNavigationEnabled)"
)
case .unavailable:
state.phase = .error(
.appGroupUnavailable,
@@ -457,6 +648,7 @@ public final class KeyboardViewController: UIInputViewController {
default:
return
}
guard !state.micDisabled else { return }
guard hasFullAccess else {
let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.unknown(msg), message: msg)
@@ -731,11 +923,18 @@ public final class KeyboardViewController: UIInputViewController {
guard let self else { return }
let polishMode = runtimeStore.polishModeForPipeline
let overrideProviderId = runtimeStore.polishProviderIdOverride
let preceding = self.textDocumentProxy.documentContextBeforeInput
let polishContext = PolishContext(
appContext: runtimeStore.detectedAppContext?.context ?? .unknown,
intensity: runtimeStore.polishIntensity,
precedingText: preceding
)
do {
let polished = try await self.polisher.polish(
trimmed,
mode: polishMode,
providerIdOverride: overrideProviderId
providerIdOverride: overrideProviderId,
context: polishContext
)
self.textDocumentProxy.insertText(polished)
self.state.lastTranscript = ""
@@ -773,16 +972,12 @@ public final class KeyboardViewController: UIInputViewController {
self.scheduleAutoClearError()
}
} catch let polishError as PolishingService.PolishError where polishError == .missingAPIKey {
// v0.2.0: local engine + cloud polish toggle on, but the
// user hasn't entered an API key. Insert the raw transcript
// (so the user doesn't lose what they said) and surface the
// same "fill in your key" hint we use in the cloud path.
self.textDocumentProxy.insertText(trimmed)
self.state.lastTranscript = ""
self.state.phase = .error(
.llm(.noAPIKey),
message: ExtL10n.string("keyboard.error.llm.noApiKey")
)
let message = runtimeStore.engineMode == "local"
? ExtL10n.string("keyboard.error.llm.localPolishUnavailable")
: ExtL10n.string("keyboard.error.llm.noApiKey")
self.state.phase = .error(.llm(.noAPIKey), message: message)
self.scheduleAutoClearError()
} catch {
// Network / timeout / decoding fall back to the raw
@@ -981,8 +1176,6 @@ public final class KeyboardViewController: UIInputViewController {
}
private func debug(_ message: String) {
#if DEBUG
print("🎙️[KeyboardVC] \(message)")
#endif
keyboardExtLog.info("\(message, privacy: .public)")
}
}
@@ -41,7 +41,12 @@ public struct AppGroupPersistor {
// the keyboard stays open.
state.translationTargetLocaleId = store.translationTargetLocaleId
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.localModeCloudPolishEnabled = store.localModeCloudPolishEnabled
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
: ""
// v0.2.0: iOS `SpeechAnalyzer` is always ready; mirror that
// into the State flags so downstream consumers see the same
// shape they did when the previous Qwen3 stack reported "ready".
@@ -95,6 +100,11 @@ public struct AppGroupPersistor {
state.translationTargetLocaleId = store.translationTargetLocaleId
}
state.handednessPreference = store.handednessPreference
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.micDisabled = store.isCloudAPIKeyMissingForVoiceInput
state.micDisabledHint = store.isCloudAPIKeyMissingForVoiceInput
? ExtL10n.string("keyboard.mic.disabled.missingApiKey")
: ""
// v0.2.0: iOS `SpeechAnalyzer` is always ready. Keep these
// toggles here so the keyboard UI doesn't flicker if the host
// app briefly clears them while refactoring.
@@ -0,0 +1,41 @@
// KeyboardSoundFeedback.swift
// OSGKeyboard · Keyboard Extension
//
// Plays the built-in iOS keyboard click sounds so the custom bottom-row
// keys (space / return / delete) sound identical to the stock keyboard.
import UIKit
import AudioToolbox
/// `UIDevice.playInputClick()`
/// true
/// `UIInputView`
extension UIInputView: @retroactive UIInputViewAudioFeedback {
public var enableInputClicksWhenVisible: Bool { true }
}
/// / /
///
/// / `playInputClick()`
/// / `playInputClick()`
/// `1155`
/// 访
enum KeyboardSoundFeedback {
///
private static let deleteSoundID: SystemSoundID = 1155
///
@MainActor
static func keyClick() {
UIDevice.current.playInputClick()
}
///
static func deleteClick() {
// 访
// 线
DispatchQueue.global(qos: .userInteractive).async {
AudioServicesPlaySystemSound(deleteSoundID)
}
}
}
+263
View File
@@ -0,0 +1,263 @@
// CursorDragPad.swift
// OSGKeyboard · Keyboard Extension
//
// SwiftUI layout wrapper for a UIKit pan recognizer. SwiftUI gestures
// can be unreliable in keyboard-extension hosting views; keeping the
// recognizer in UIKit preserves the existing layout while avoiding that
// failure mode.
import SwiftUI
import UIKit
import os
private let cursorDragLog = Logger(subsystem: "com.osgkeyboard.ios", category: "CursorDrag")
struct CursorDragPad: UIViewRepresentable {
let enabled: Bool
let onPressingChanged: (Bool) -> Void
let moveHorizontal: (Int) -> Void
let moveVertical: (Int) -> Void
func makeUIView(context: Context) -> CursorDragPadUIView {
cursorDragLog.info("makeUIView (enabled=\(enabled))")
let view = CursorDragPadUIView()
view.coordinator = context.coordinator
view.isPadEnabled = enabled
return view
}
func updateUIView(_ uiView: CursorDragPadUIView, context: Context) {
context.coordinator.onPressingChanged = onPressingChanged
context.coordinator.moveHorizontal = moveHorizontal
context.coordinator.moveVertical = moveVertical
uiView.isPadEnabled = enabled
}
func makeCoordinator() -> Coordinator {
Coordinator(
onPressingChanged: onPressingChanged,
moveHorizontal: moveHorizontal,
moveVertical: moveVertical
)
}
final class Coordinator {
var onPressingChanged: (Bool) -> Void
var moveHorizontal: (Int) -> Void
var moveVertical: (Int) -> Void
init(
onPressingChanged: @escaping (Bool) -> Void,
moveHorizontal: @escaping (Int) -> Void,
moveVertical: @escaping (Int) -> Void
) {
self.onPressingChanged = onPressingChanged
self.moveHorizontal = moveHorizontal
self.moveVertical = moveVertical
}
}
}
final class CursorDragPadUIView: UIView, UIGestureRecognizerDelegate {
weak var coordinator: CursorDragPad.Coordinator?
var isPadEnabled = true {
didSet {
isUserInteractionEnabled = isPadEnabled
applyIdleTint()
}
}
// MARK: - Pad tint
// MUST stay non-zero. When embedded via `UIViewRepresentable`, a fully
// transparent (alpha 0) background makes SwiftUI's host treat the region
// as empty pass-through space and the pad stops receiving touches. A tiny
// alpha (just above UIKit's 0.01 hit-test threshold) keeps the pad fully
// draggable while remaining imperceptible.
//
// The keyboard surface itself is transparent (system chrome shows
// through), so there is no fixed colour to match; `systemGray4` tracks
// the system keyboard's grey in both light and dark and, at ~2% alpha,
// blends invisibly. `withAlphaComponent` on a dynamic colour can freeze
// the current trait, so resolve per-trait to stay appearance-adaptive.
private static let padTint = UIColor { traits in
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
}
private static var idleTint: UIColor { padTint }
private static var activeTint: UIColor { padTint }
private func applyIdleTint() {
backgroundColor = isPadEnabled ? Self.idleTint : .clear
}
private var lastTranslation = CGPoint.zero
private var horizontalCarry: CGFloat = 0
private var verticalCarry: CGFloat = 0
private var didFireBeginHaptic = false
/// Once the finger clears the dead zone, lock to one axis so slight
/// diagonal jitter does not flip between horizontal and vertical steps.
private var lockedAxis: LockedAxis?
private enum LockedAxis {
case horizontal
case vertical
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = Self.idleTint
isMultipleTouchEnabled = false
isUserInteractionEnabled = true
let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
pan.delegate = self
pan.minimumNumberOfTouches = 1
pan.maximumNumberOfTouches = 1
pan.cancelsTouchesInView = false
pan.delaysTouchesBegan = false
pan.delaysTouchesEnded = false
addGestureRecognizer(pan)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func didMoveToWindow() {
super.didMoveToWindow()
let size = "\(Int(bounds.width))x\(Int(bounds.height))"
cursorDragLog.info("didMoveToWindow size=\(size, privacy: .public) attached=\(self.window != nil)")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let hit = super.hitTest(point, with: event)
if hit === self {
cursorDragLog.debug("hitTest inside pad")
}
return hit
}
// Raw touch delivery drives the "drag mode" state so a static hold
// (which a pan recognizer ignores until the finger moves) already
// switches the keyboard into cursor-drag chrome.
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesBegan(touches, with: event)
guard isPadEnabled else { return }
backgroundColor = Self.activeTint
coordinator?.onPressingChanged(true)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
applyIdleTint()
coordinator?.onPressingChanged(false)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
applyIdleTint()
coordinator?.onPressingChanged(false)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
guard isPadEnabled, let coordinator else { return }
switch gesture.state {
case .began:
resetGestureState()
backgroundColor = Self.activeTint
coordinator.onPressingChanged(true)
cursorDragLog.info("pan began")
case .changed:
handlePanChanged(gesture, coordinator: coordinator)
case .ended, .cancelled, .failed:
resetGestureState()
applyIdleTint()
coordinator.onPressingChanged(false)
default:
break
}
}
private func handlePanChanged(
_ gesture: UIPanGestureRecognizer,
coordinator: CursorDragPad.Coordinator
) {
let translation = gesture.translation(in: self)
let delta = CGPoint(
x: translation.x - lastTranslation.x,
y: translation.y - lastTranslation.y
)
lastTranslation = translation
let deadZone: CGFloat = 6
guard max(abs(translation.x), abs(translation.y)) > deadZone else { return }
if !didFireBeginHaptic {
didFireBeginHaptic = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
if lockedAxis == nil {
lockedAxis = abs(translation.x) >= abs(translation.y) ? .horizontal : .vertical
}
switch lockedAxis {
case .horizontal:
horizontalCarry += delta.x
let threshold = stepThreshold(for: translation.x)
let steps = consumeCarry(&horizontalCarry, threshold: threshold)
if steps != 0 {
coordinator.moveHorizontal(steps)
}
case .vertical:
verticalCarry += delta.y
let threshold = stepThreshold(for: translation.y) * Self.verticalSensitivityDamping
let steps = consumeCarry(&verticalCarry, threshold: threshold)
if steps != 0 {
coordinator.moveVertical(steps)
}
case .none:
break
}
}
private func resetGestureState() {
lastTranslation = .zero
horizontalCarry = 0
verticalCarry = 0
didFireBeginHaptic = false
lockedAxis = nil
}
/// Vertical steps move in large character chunks, so require more finger
/// travel per step than horizontal to keep them from firing too fast.
/// Higher = less sensitive.
private static let verticalSensitivityDamping: CGFloat = 2.6
/// Farther drag means a smaller threshold and faster stepping,
/// capped so long swipes remain controllable.
private func stepThreshold(for totalAxisDistance: CGFloat) -> CGFloat {
let deadZone: CGFloat = 6
let accelerated = max(0, abs(totalAxisDistance) - deadZone)
let progress = min(1, accelerated / 100)
return 12 - progress * 7
}
private func consumeCarry(_ carry: inout CGFloat, threshold: CGFloat) -> Int {
guard threshold > 0 else { return 0 }
let steps = Int(carry / threshold)
if steps != 0 {
carry -= CGFloat(steps) * threshold
}
return steps
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer
) -> Bool {
true
}
}
+104 -29
View File
@@ -25,8 +25,12 @@ private enum KeyboardLayoutMetrics {
static let bottomActionRowHeight: CGFloat = 48
static let bottomActionFixedWidth: CGFloat = 86
static let bottomActionSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line (4 pt 8 pt, +100%).
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs
/// Gap between the top chip row and the transcript / hint line.
/// Tightened (8 4) so the "" line hugs the chip row. The
/// space reclaimed here and from `actionClusterTopGap` is added back
/// into `actionClusterBottomGap`, keeping `totalHeight` constant while
/// nudging the mic up toward the vertical centre.
static let topBarToTranscriptSpacing: CGFloat = Spacing.xs / 2
/// Outer inset for the bottom action row from screen edges (8 pt 24 pt, +200%).
static let sideActionHorizontalInset: CGFloat = Spacing.xs * 3
@@ -37,16 +41,18 @@ private enum KeyboardLayoutMetrics {
static let transcriptLineHeight: CGFloat = 22
/// mic (121) + gap (8) + bottom row (48) = 177 pt
static let actionClusterHeight: CGFloat = micSize + micToButtonGap + bottomActionRowHeight
/// Gap between transcript line and mic (30% from former 16 pt).
static let actionClusterTopGap: CGFloat = Spacing.md * 0.7
/// Minimal gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = Spacing.xs / 2
/// Gap between transcript line and mic. Tightened (11.2 4) to pull
/// the mic up; the reclaimed space moves to `actionClusterBottomGap`.
static let actionClusterTopGap: CGFloat = Spacing.xs / 2
/// Gap below the bottom action row.
static let actionClusterBottomGap: CGFloat = 6
static var headerBandHeight: CGFloat {
topBarHeight + topBarToTranscriptSpacing + transcriptLineHeight
}
/// 2 + 68 + 11.2 + 177 + 4 + 1 = 263.2 pt
/// 2 + 64 + 4 + 177 + 15.2 + 1 = 263.2 pt (unchanged; the mic cluster
/// just sits higher now that the top gaps moved to the bottom gap).
static var totalHeight: CGFloat {
outerPaddingTop
+ headerBandHeight
@@ -70,6 +76,17 @@ public struct KeyboardRootView: View {
/// in `KeyboardViewController` (see `KeyboardLayoutMetrics.totalHeight`).
static let totalHeight: CGFloat = KeyboardLayoutMetrics.totalHeight
// MARK: - Cursor-drag pad geometry
/// Mic disc side length.
static let micSize: CGFloat = KeyboardLayoutMetrics.micSize
/// Vertical offset from the keyboard's top edge to the mic disc.
static let micTopOffset: CGFloat = KeyboardLayoutMetrics.outerPaddingTop
+ KeyboardLayoutMetrics.headerBandHeight
+ KeyboardLayoutMetrics.actionClusterTopGap
/// Horizontal inset the side pads should respect.
static let sideInset: CGFloat = KeyboardLayoutMetrics.sideActionHorizontalInset
private var palette: ThemePalette {
colorScheme == .dark ? Palette.dark : Palette.light
}
@@ -109,6 +126,7 @@ public struct KeyboardRootView: View {
}
}
.animation(.easeInOut(duration: 0.18), value: state.hasCompletedOnboarding)
.animation(.easeInOut(duration: 0.12), value: state.cursorDragActive)
}
/// Top chip row + transcript / hint line.
@@ -121,9 +139,12 @@ public struct KeyboardRootView: View {
phase: state.phase,
transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
micDisabled: state.micDisabled,
micDisabledHint: state.micDisabledHint,
isLocalEngine: state.isLocalEngine,
localModelsReady: state.localModelsReady,
localModelsLoaded: state.localModelsLoaded,
cursorDragHintActive: state.cursorDragActive,
openSettings: state.openSettings,
startFlowSession: state.startFlowSession
)
@@ -142,7 +163,14 @@ public struct KeyboardRootView: View {
}
// App context is auto-detected on each mic press no UI.
if state.isTranslationChipVisible {
TranslationChip(state: state)
TranslationChip(
palette: palette,
targetLocaleId: state.translationTargetLocaleId,
onSelect: state.setTranslationTargetLocaleId
)
// Decouple the open picker from the keyboard's 1 Hz App
// Group poll so scrolling doesn't reset / dismiss it.
.equatable()
}
Spacer(minLength: 0)
Button(action: state.openSettings) {
@@ -162,18 +190,37 @@ public struct KeyboardRootView: View {
// MARK: - Action cluster
/// Mic centred above a bottom row: delete · space · return (or swapped).
/// The side cursor-drag pads are SwiftUI layout wrappers around UIKit
/// pan recognizers, avoiding SwiftUI gesture delivery issues in
/// keyboard extensions.
private var micActionRow: some View {
let editingBlocked = voiceInputBlocksEditing
let swapKeys = state.handednessPreference.swapsActionKeys
let micDisabled = state.micDisabled
let cursorPadsEnabled = state.cursorDragNavigationEnabled && !editingBlocked
// Dragging hides the mic + bottom keys (kept in the layout via
// opacity so the pads' hit area never shifts mid-gesture) and lets
// the cursor-drag chrome take over.
let dragging = state.cursorDragActive
return VStack(spacing: KeyboardLayoutMetrics.micToButtonGap) {
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
HStack(spacing: 0) {
cursorDragPad(enabled: cursorPadsEnabled)
RecordButton(
phase: buttonPhase,
level: state.level,
remainingSeconds: state.phase == .recording ? state.utteranceRemainingSeconds : nil,
isEnabled: !micDisabled,
onToggle: state.tapMic
)
.frame(width: KeyboardLayoutMetrics.micSize, height: KeyboardLayoutMetrics.micSize)
.opacity(dragging ? 0 : 1)
cursorDragPad(enabled: cursorPadsEnabled)
}
.frame(height: KeyboardLayoutMetrics.micSize)
HStack(spacing: KeyboardLayoutMetrics.bottomActionSpacing) {
if swapKeys {
@@ -186,11 +233,23 @@ public struct KeyboardRootView: View {
bottomReturnButton(disabled: editingBlocked)
}
}
.opacity(dragging ? 0 : 1)
}
.padding(.horizontal, KeyboardLayoutMetrics.sideActionHorizontalInset)
.frame(maxWidth: .infinity)
}
private func cursorDragPad(enabled: Bool) -> some View {
CursorDragPad(
enabled: enabled,
onPressingChanged: state.setCursorDragActive,
moveHorizontal: state.moveCursorHorizontal,
moveVertical: state.moveCursorVertical
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.contentShape(Rectangle())
}
private func bottomDeleteButton(disabled: Bool) -> some View {
RepeatingDeleteButton(disabled: disabled) {
state.deleteBackward()
@@ -276,17 +335,39 @@ private struct TranscriptLine: View {
let phase: KeyboardViewController.State.Phase
let transcript: String
let flowSessionActive: Bool
let micDisabled: Bool
let micDisabledHint: String
let isLocalEngine: Bool
let localModelsReady: Bool
let localModelsLoaded: Bool
let cursorDragHintActive: Bool
let openSettings: () -> Void
let startFlowSession: () -> Void
var body: some View {
ZStack {
switch phase {
case .idle:
if isLocalEngine, !localModelsReady {
// While dragging the caret, the whole mic cluster + transcript
// line give way to the cursor-drag overlay, so hide this line's
// "" / status text entirely.
if !cursorDragHintActive {
phaseContent
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
@ViewBuilder
private var phaseContent: some View {
switch phase {
case .idle:
if micDisabled {
Text(micDisabledHint)
.font(TypeStyle.caption)
.foregroundStyle(palette.warning)
.lineLimit(1)
.truncationMode(.tail)
} else if isLocalEngine, !localModelsReady {
Button(action: openSettings) {
HStack(spacing: 4) {
Text(ExtL10n.string("keyboard.models.notDownloaded"))
@@ -335,14 +416,11 @@ private struct TranscriptLine: View {
.truncationMode(.head)
.frame(maxWidth: .infinity)
case .processing:
HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent)
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
}
Text(transcript.isEmpty ? ExtL10n.string("keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.truncationMode(.tail)
case .error(_, let msg):
Text(msg ?? "")
.font(TypeStyle.caption)
@@ -365,10 +443,7 @@ private struct TranscriptLine: View {
}
.buttonStyle(.plain)
.accessibilityHint(ExtL10n.text("keyboard.deniedHint"))
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md)
}
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String {
+10 -2
View File
@@ -21,6 +21,7 @@ struct RecordButton: View {
let level: Double // 0...1
/// Seconds left in the current utterance; shown only while recording.
let remainingSeconds: Int?
let isEnabled: Bool
let onToggle: () -> Void
@State private var breath: Bool = false
@@ -29,11 +30,13 @@ struct RecordButton: View {
phase: Phase,
level: Double,
remainingSeconds: Int? = nil,
isEnabled: Bool = true,
onToggle: @escaping () -> Void
) {
self.phase = phase
self.level = level
self.remainingSeconds = remainingSeconds
self.isEnabled = isEnabled
self.onToggle = onToggle
}
@@ -103,6 +106,8 @@ struct RecordButton: View {
.foregroundStyle(.white)
.monospacedDigit()
.contentTransition(.numericText())
//
.offset(y: 3)
}
WaveformView(
level: level,
@@ -110,13 +115,15 @@ struct RecordButton: View {
active: true
)
.frame(width: 73, height: 32)
.opacity(0.4)
.scaleEffect(0.96)
}
.transition(.opacity)
case .processing:
ProgressView()
.progressViewStyle(.circular)
.tint(palette.textPrimary)
.scaleEffect(2.5)
.scaleEffect(1.25)
case .error:
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 32, weight: .medium))
@@ -129,8 +136,9 @@ struct RecordButton: View {
.animation(Motion.soft, value: remainingSeconds)
}
.contentShape(Circle())
.opacity(isEnabled ? 1 : 0.45)
.onTapGesture {
guard phase != .processing else { return }
guard isEnabled, phase != .processing else { return }
onToggle()
}
.onAppear { breath = (phase == .recording) }
+29 -57
View File
@@ -4,7 +4,6 @@
// Bottom-row action keys: repeating delete, space, and return.
import SwiftUI
import UIKit
import OSGKeyboardShared
// MARK: - Layout metrics
@@ -17,36 +16,8 @@ private enum ToolbarButtonMetrics {
static let pressOverlayOpacity: CGFloat = 0.18
}
// MARK: - Haptics
private enum ToolbarHaptics {
@MainActor
static func tap() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
}
// MARK: - Press styling
private struct ToolbarKeyPressStyle: ButtonStyle {
let cornerRadius: CGFloat
func makeBody(configuration: Configuration) -> some View {
configuration.label
.overlay {
if configuration.isPressed {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(Color.black.opacity(ToolbarButtonMetrics.pressOverlayOpacity))
}
}
.scaleEffect(configuration.isPressed ? ToolbarButtonMetrics.pressScale : 1)
.animation(.easeOut(duration: 0.1), value: configuration.isPressed)
.sensoryFeedback(.impact(weight: .light), trigger: configuration.isPressed) { _, pressed in
pressed
}
}
}
private struct ToolbarKeySurface<Content: View>: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.themePalette) private var palette
@@ -120,7 +91,7 @@ struct RepeatingDeleteButton: View {
guard !disabled, !isPressing else { return }
isPressing = true
repeatStartedAt = Date()
ToolbarHaptics.tap()
KeyboardSoundFeedback.deleteClick()
action()
startRepeating()
}
@@ -143,6 +114,7 @@ struct RepeatingDeleteButton: View {
guard !Task.isCancelled, isPressing else { return }
let anchor = repeatStartedAt ?? Date()
while !Task.isCancelled, isPressing {
KeyboardSoundFeedback.deleteClick()
action()
let elapsed = Date().timeIntervalSince(anchor)
let wait = interval(for: elapsed)
@@ -186,39 +158,39 @@ struct RectangularToolbarButton: View {
self.action = action
}
@State private var isPressing = false
var body: some View {
Button(action: action) {
Group {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
ToolbarKeySurface(isPressed: isPressing, cornerRadius: ToolbarButtonMetrics.cornerRadius) {
if spaceStyle {
Capsule()
.fill(palette.textPrimary)
.frame(width: ToolbarButtonMetrics.spaceBarCapsuleWidth, height: 3)
} else if let systemName {
Image(systemName: systemName)
.font(.system(size: ToolbarButtonMetrics.iconSize, weight: .semibold))
.foregroundStyle(palette.textPrimary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(keyBackground)
.overlay(
RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.stroke(palette.dividerStrong, lineWidth: 0.5)
)
}
.buttonStyle(ToolbarKeyPressStyle(cornerRadius: ToolbarButtonMetrics.cornerRadius))
.disabled(disabled)
.contentShape(Rectangle())
.gesture(pressGesture)
.opacity(disabled ? 0.38 : 1)
.allowsHitTesting(!disabled)
.accessibilityLabel(Text(label))
.accessibilityAddTraits(.isButton)
}
@Environment(\.colorScheme) private var colorScheme
private var keyBackground: some View {
let fill = colorScheme == .dark
? Color(red: 0.20, green: 0.20, blue: 0.22)
: palette.surfaceElevated
return RoundedRectangle(cornerRadius: ToolbarButtonMetrics.cornerRadius, style: .continuous)
.fill(fill)
// Button
private var pressGesture: some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { _ in
guard !disabled, !isPressing else { return }
isPressing = true
KeyboardSoundFeedback.keyClick()
action()
}
.onEnded { _ in
isPressing = false
}
}
}
+24 -13
View File
@@ -29,10 +29,27 @@
import SwiftUI
import OSGKeyboardShared
struct TranslationChip: View {
@Environment(\.themePalette) private var palette: ThemePalette
struct TranslationChip: View, Equatable {
/// Passed in as a value (not read from `@Environment`) so the chip can
/// be wrapped in `.equatable()` at the call site: `EquatableView`
/// suppresses environment-driven refreshes, so injecting the palette
/// here keeps colours correct across dark/light switches.
let palette: ThemePalette
/// The active target-locale id (`offLocaleId` == translation off).
let targetLocaleId: String
/// Writes the picked locale id wired to `state.setTranslationTargetLocaleId`.
let onSelect: (String) -> Void
@ObservedObject var state: KeyboardViewController.State
/// Only `palette` and `targetLocaleId` drive the visuals; the
/// `onSelect` closure is deliberately excluded from equality. Because
/// the keyboard polls the App Group at 1 Hz (each poll re-publishes the
/// `KeyboardState`), the parent view re-renders every second. Without
/// this, SwiftUI would rebuild the `Menu` on every poll dismissing an
/// open picker or snapping its scroll position back to the top. With
/// `.equatable()` the picker is rebuilt only on a real state change.
nonisolated static func == (lhs: TranslationChip, rhs: TranslationChip) -> Bool {
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
}
var body: some View {
Menu {
@@ -43,9 +60,9 @@ struct TranslationChip: View {
// derived from it.
ForEach(TranslationLanguageCatalog.all) { language in
Button {
state.setTranslationTargetLocaleId(language.id)
onSelect(language.id)
} label: {
if language.id == currentSelectionId {
if language.id == targetLocaleId {
Label(displayLabel(for: language), systemImage: "checkmark")
} else {
Text(displayLabel(for: language))
@@ -62,8 +79,8 @@ struct TranslationChip: View {
@ViewBuilder
private var label: some View {
let target = TranslationLanguageCatalog.resolve(state.translationTargetLocaleId)
let enabled = state.translationEnabled
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
let enabled = targetLocaleId != TranslationLanguageCatalog.offLocaleId
HStack(spacing: 4) {
Image(systemName: enabled ? "character.bubble" : "character.bubble.fill")
@@ -80,12 +97,6 @@ struct TranslationChip: View {
.overlay(Capsule().stroke(stroke(enabled: enabled), lineWidth: 0.5))
}
/// Active selection id the chip derives "on" from a non-off
/// locale id, so reading `translationTargetLocaleId` is enough.
private var currentSelectionId: String {
state.translationTargetLocaleId
}
private func displayLabel(for language: TranslationLanguage) -> String {
if language.id == TranslationLanguageCatalog.offLocaleId {
return ExtL10n.string("keyboard.translation.offMenu")
+5 -1
View File
@@ -128,6 +128,8 @@
"keyboard.openSettingsA11y" = "Open OSGKeyboard settings";
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
"keyboard.tapToTalkA11y" = "Tap to talk";
"keyboard.cursorDrag.hint" = "Hold and drag to move the cursor";
"keyboard.cursorDrag.centerHint" = "Drag to move the cursor";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Voice session off";
@@ -155,6 +157,8 @@
"keyboard.error.manualOpenDictateLocal" = "System blocked the jump. Open OSGKeyboard for on-device dictation, then return.";
"keyboard.error.manualOpenDictate" = "System blocked the jump. Open OSGKeyboard to record, then return.";
"keyboard.error.llm.noApiKey" = "API key missing · configure it in the main app";
"keyboard.mic.disabled.missingApiKey" = "Fill in API key in Settings first";
"keyboard.error.llm.localPolishUnavailable" = "Built-in polish unavailable · inserted raw text";
"keyboard.error.llm.unauthorized" = "Invalid API key (401) · check main app settings";
"keyboard.error.llm.rateLimited" = "Rate limited (429) · try again later";
@@ -234,6 +238,6 @@
"keyboard.appContext.chip.unknown" = "General";
"keyboard.appContext.menu.code" = "Code — preserve identifiers, no natural-language wrap";
"keyboard.appContext.menu.email" = "Email — polite, professional, paragraph-broken";
"keyboard.appContext.menu.chat" = "Chat — short, casual, emoji-friendly";
"keyboard.appContext.menu.chat" = "Chat — short, casual, natural tone";
"keyboard.appContext.menu.document" = "Document — long-form, structured";
"keyboard.appContext.menu.unknown" = "General — neutral tone";
@@ -128,6 +128,8 @@
"keyboard.openSettingsA11y" = "打开 OSGKeyboard 设置";
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.tapToTalkA11y" = "点按说话";
"keyboard.cursorDrag.hint" = "按住并拖动以移动光标";
"keyboard.cursorDrag.centerHint" = "拖动移动光标";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "语音会话未启动";
@@ -155,6 +157,8 @@
"keyboard.error.manualOpenDictateLocal" = "系统拒绝了跳转,请手动打开 OSGKeyboard 完成本地转写";
"keyboard.error.manualOpenDictate" = "系统拒绝了跳转,请手动打开 OSGKeyboard 录音";
"keyboard.error.llm.noApiKey" = "未配置 API Key · 请在主 App 设置中填写";
"keyboard.mic.disabled.missingApiKey" = "请先在设置中填写 API Key";
"keyboard.error.llm.localPolishUnavailable" = "内置润色不可用 · 已插入原始文本";
"keyboard.error.llm.unauthorized" = "API Key 无效 (401) · 请检查主 App 设置";
"keyboard.error.llm.rateLimited" = "API 限流 (429) · 请稍后再试";
@@ -234,6 +238,6 @@
"keyboard.appContext.chip.unknown" = "通用";
"keyboard.appContext.menu.code" = "代码 — 保留标识符、不做自然语言化";
"keyboard.appContext.menu.email" = "邮件 — 礼貌专业、合理分段";
"keyboard.appContext.menu.chat" = "聊天 — 简短随意、可带 emoji";
"keyboard.appContext.menu.chat" = "聊天 — 简短随意、保留口语";
"keyboard.appContext.menu.document" = "文档 — 长文、结构化";
"keyboard.appContext.menu.unknown" = "通用 — 中性口吻";
+1 -1
View File
@@ -16,7 +16,7 @@ import SwiftUI
/// `DesignSystem/ThemedRoot.swift`. Token names mirror the previous
/// `Palette` static API so existing call sites (`Palette.background` etc.)
/// still compile and resolve through the legacy static accessors below.
public struct ThemePalette: Sendable {
public struct ThemePalette: Sendable, Equatable {
public let background: Color
public let surface: Color
public let surfaceElevated: Color
+1 -1
View File
@@ -46,7 +46,7 @@ public enum AppContext: String, Codable, Sendable, CaseIterable {
case .email:
return "Email context: you may add a polite greeting or sign-off if the user clearly forgot one. Reasonable paragraph breaks. Keep tone professional but not stiff."
case .chat:
return "Chat context: keep it short, conversational, and emoji-friendly. Drop formalities. Preserve the speaker's casual voice."
return "Chat context: keep it short, conversational, and natural. Drop formalities. Preserve the speaker's casual voice. Do not add emojis."
case .document:
return "Document context: add structure — split into paragraphs, use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
case .unknown:
+9 -5
View File
@@ -52,13 +52,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
id: "deepseek",
name: "DeepSeek",
defaultBaseURL: "https://api.deepseek.com/v1",
// v0.2.0: bumped default to `deepseek-v4-flash` for the
// local-mode cloud-polish toggle. `deepseek-chat` is
// retained as a valid user-overridable model name; only
// the default is updated.
defaultModel: "deepseek-v4-flash",
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
blurb: "deepseek-v4-flash · 默认 · 快速且中文友好"
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in",
// Local engine only never shown in cloud-engine pickers.
isUserSelectable: false
),
.init(
id: "qwen",
@@ -96,4 +94,10 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
public static func provider(id: String) -> LLMProvider {
presets.first(where: { $0.id == id }) ?? .presets[0]
}
/// Presets the user may pick in Settings / onboarding. DeepSeek is
/// excluded it is wired exclusively to the local engine.
public static var userSelectablePresets: [LLMProvider] {
presets.filter(\.isUserSelectable)
}
}
@@ -7,8 +7,7 @@
//
// Sources (mutually exclusive per entry):
// - `.manual` user typed it in by hand
// - `.history` auto-extracted from the user's transcription
// history by `DictionaryLearner`
// - `.history` legacy auto-learned entries (migrated to `.manual`)
// - `.contacts` imported from the iOS Contacts framework
// - `.recentEdit` extracted from edits the user made to a
// polished transcript before sending
@@ -104,13 +103,124 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
}
}
extension PersonalDictionary.Entry {
/// Lightweight category inference for manual adds and the history
/// learner. Users can re-classify later from Settings.
public static func inferCategory(for term: String) -> Category {
let hasUpper = term.contains(where: { $0.isUppercase })
let hasDigit = term.contains(where: { $0.isNumber })
let hasLatin = term.unicodeScalars.contains { scalar in
CharacterSet.letters.contains(scalar) && scalar.isASCII
}
if hasUpper, !term.contains(where: { $0.isLowercase }) {
return .acronym
}
if hasDigit {
return .productName
}
if !hasLatin {
return .properNoun
}
return .productName
}
}
extension PersonalDictionary {
public static let empty = PersonalDictionary()
/// Built-in terms always included in LLM prompts. Never persisted
/// and never shown in the Settings personal-dictionary UI.
public static let systemEntries: [Entry] = [
Entry(
id: UUID(uuidString: "A0000000-0000-4000-8000-000000000001")!,
term: "OSGKeyboard",
aliases: [],
category: .productName,
source: .manual,
createdAt: Date(timeIntervalSince1970: 0),
usageCount: 0
),
]
/// User entries plus built-in system terms (deduped by term).
public var effectiveEntries: [Entry] {
var merged = Self.systemEntries
let systemTerms = Set(Self.systemEntries.map { $0.term.lowercased() })
for entry in entries where !systemTerms.contains(entry.term.lowercased()) {
merged.append(entry)
}
return merged
}
/// Case-insensitive lookup by canonical term.
public func entry(matchingTerm term: String) -> Entry? {
let key = term.lowercased()
return entries.first { $0.term.lowercased() == key }
}
/// Insert or update a manual entry. Returns the saved entry.
@discardableResult
public mutating func upsertManual(
term: String,
existingID: UUID? = nil,
regenerateAliases: Bool = false
) -> Entry? {
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let category = Entry.inferCategory(for: trimmed)
if let existingID,
let idx = entries.firstIndex(where: { $0.id == existingID }) {
var entry = entries[idx]
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
entry.term = trimmed
entry.category = category
entry.source = .manual
if termChanged || regenerateAliases {
entry.aliases = []
}
entries[idx] = entry
return entry
}
if let idx = entries.firstIndex(where: {
$0.term.caseInsensitiveCompare(trimmed) == .orderedSame
}) {
var entry = entries[idx]
entry.term = trimmed
entry.category = category
entry.source = .manual
entries[idx] = entry
return entry
}
let entry = Entry(
term: trimmed,
aliases: [],
category: category,
source: .manual
)
entries.append(entry)
return entry
}
public mutating func updateAliases(for entryID: UUID, aliases: [String]) {
guard let idx = entries.firstIndex(where: { $0.id == entryID }) else { return }
let cleaned = aliases
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let termLower = entries[idx].term.lowercased()
entries[idx].aliases = Array(
Set(cleaned.filter { $0.lowercased() != termLower })
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
}
/// Renders the entire dictionary as a prompt fragment. Entries
/// are grouped by category so the LLM can scan quickly. Empty
/// dictionary returns "" so the caller can blindly concatenate.
public func promptFragment() -> String {
let entries = effectiveEntries
guard !entries.isEmpty else { return "" }
let grouped = Dictionary(grouping: entries, by: { $0.category })
var lines: [String] = []
+29 -14
View File
@@ -9,14 +9,9 @@
import Foundation
public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// Engine is in pure ASR mode (local + cloud-polish-off). The LLM
/// is never called; the raw transcript is inserted as-is. This
/// value is mostly a UI default the actual behaviour is
/// determined by `engineMode` + `localModeCloudPolishEnabled`.
case off
/// Drop only isolated filler words ( / / / / )
/// and obvious duplicated fragments. Everything else stays.
/// and obvious duplicated fragments. Punctuation and structure
/// formatting still apply at every intensity level.
case light
/// Correction + light polish: drop fillers, fix homophone errors,
@@ -34,7 +29,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// and the keyboard extension.
public var labelKey: String {
switch self {
case .off: return "polish.intensity.off"
case .light: return "polish.intensity.light"
case .medium: return "polish.intensity.medium"
case .heavy: return "polish.intensity.heavy"
@@ -45,7 +39,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// story as `labelKey`.
public var descriptionKey: String {
switch self {
case .off: return "polish.intensity.off.desc"
case .light: return "polish.intensity.light.desc"
case .medium: return "polish.intensity.medium.desc"
case .heavy: return "polish.intensity.heavy.desc"
@@ -57,16 +50,38 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
/// non-ambiguous constraint per call.
public var promptGuideline: String {
switch self {
case .off:
return "Do not change the input at all. Output the original text verbatim."
case .light:
return "Only remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok) and obvious duplicated fragments. Do not change any other words, word order, or punctuation."
return """
Light rewrite: remove isolated filler words (, , , , , , ok, um, uh) and obvious duplicated fragments only. \
Do not rephrase otherwise-clear wording. \
Still restore punctuation, sentence breaks, and content-triggered structure (lists, paragraphs) per the global output contract.
"""
case .medium:
return "Correct obvious speech-recognition errors (homophones, missing/extra characters). Remove filler words and duplicated fragments. Adjust obviously-broken word order. Add punctuation. Do not restructure sentences, invent facts, or change the speaker's voice."
return """
Medium rewrite: fix obvious ASR errors (homophones, missing/extra characters), remove fillers and duplicated fragments, \
adjust obviously-broken word order. Preserve the speaker's voice. \
Still restore punctuation, sentence breaks, and content-triggered structure per the global output contract. \
Do not invent facts or change numbers/proper nouns.
"""
case .heavy:
return "Apply medium corrections, then optionally restructure: split long sentences, auto-number enumerated items into markdown lists, group related ideas into paragraphs. Preserve every fact, number, and proper noun."
return """
Heavy rewrite: apply medium corrections, then you may reorganize paragraphs, split long sentences, and listify enumerated content. \
Punctuation and structure are mandatory at every intensity. \
Preserve every fact, number, and proper noun. Do not add information.
"""
}
}
/// Legacy persisted value `"off"` maps to `.medium` on read.
public static func resolve(storedRawValue raw: String) -> PolishIntensity {
if raw == legacyOffRawValue {
return .medium
}
return PolishIntensity(rawValue: raw) ?? .default
}
/// Raw value written by builds before the off tier was removed.
public static let legacyOffRawValue = "off"
}
extension PolishIntensity {
+71 -54
View File
@@ -51,12 +51,21 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// "on" state during init, but new writes never touch the key.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let handednessPreference = "config.handednessPreference"
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
// v0.3.0: how aggressively the LLM should rewrite transcripts.
static let polishIntensity = "config.polishIntensity"
}
@Published public var providerId: String {
didSet { defaults.set(providerId, forKey: Key.providerId) }
didSet {
defaults.set(providerId, forKey: Key.providerId)
// Keep API keys isolated per provider: switching provider in
// Settings loads that provider's key instead of reusing the
// previously selected vendor's key.
isSyncingProviderAPIKey = true
apiKey = Keychain.apiKey(for: providerId) ?? ""
isSyncingProviderAPIKey = false
}
}
@Published public var baseURL: String {
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
@@ -65,9 +74,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
didSet {
// Skip the round-trip on init we read from Keychain and
// writing the same value back is wasteful.
guard oldValue != apiKey else { return }
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
do {
try Keychain.setAPIKey(apiKey)
try Keychain.setAPIKey(apiKey, for: providerId)
} catch {
#if DEBUG
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
@@ -84,10 +93,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
@Published public var localeId: String {
didSet { defaults.set(localeId, forKey: Key.localeId) }
}
/// "local" on-device ASR only (raw transcript delivery).
/// "cloud" ASR + LLM polish (always on; modeId kept for compatibility).
/// "local" on-device ASR + built-in DeepSeek polish.
/// "cloud" on-device ASR + user's cloud LLM polish.
@Published public var engineMode: String {
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
didSet {
defaults.set(engineMode, forKey: Key.engineMode)
applyEngineModeSideEffects()
}
}
@Published public var hasCompletedOnboarding: Bool {
didSet {
@@ -162,27 +174,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
}
}
/// Whether the pipeline should run translate-and-polish (not just
/// polish). Cloud engine: any selected target locale. Local engine:
/// only when cloud polish is also enabled.
public var isTranslationEffective: Bool {
guard translationEnabled else { return false }
if isLocalEngine { return localModeCloudPolishEnabled }
return true
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool {
didSet {
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
AppGroupConfigDarwin.postConfigChanged()
}
}
/// Translation picker visibility. Cloud engine: always. Local engine:
/// only when "Cloud polish after ASR" is on translation is a
/// sub-step of that cloud LLM pass, not a standalone feature.
public var isTranslationRowVisible: Bool {
if engineMode == "cloud" { return true }
return isLocalEngine && localModeCloudPolishEnabled
/// Whether the pipeline should run translate-and-polish (not just
/// polish). Both engines honour the selected target locale.
public var isTranslationEffective: Bool {
translationEnabled
}
/// Translation picker visibility available on both engines.
public var isTranslationRowVisible: Bool { true }
/// v0.3.0: how aggressively the LLM should rewrite the ASR
/// transcript. Default is `medium` (Typeless-equivalent). The
/// `off` value never calls the LLM equivalent to "transcribe
/// only" regardless of `engineMode`.
/// transcript. Default is `medium` (Typeless-equivalent).
@Published public var polishIntensity: PolishIntensity {
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
}
@@ -200,32 +210,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
/// On-device ASR only; no cloud API required.
public var isLocalEngine: Bool { engineMode == "local" }
/// Whether a transcript produced by the local engine should be
/// sent through the cloud LLM polish step before insertion.
///
/// v0.2.0: the local engine defaults to ASR-only. When the user
/// enables "Cloud polish after ASR" (`localModeCloudPolishEnabled`)
/// we route the transcript through the configured LLM (DeepSeek by
/// default in local mode) same `PolishingService` code path the
/// cloud engine uses.
///
/// If the user hasn't entered an API key we can't run the polish
/// step; callers should check `Keychain.apiKey()` before invoking.
public var shouldPolishLocalTranscript: Bool {
isLocalEngine && localModeCloudPolishEnabled
}
/// Local engine always polishes via the built-in DeepSeek path.
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
/// v0.2.1 follow-up: when the local engine is using the cloud-
/// polish step, route the call through DeepSeek cheap, strong
/// on Chinese, and the right default for the on-device ASR
/// transcript. Other engines honor the user's configured
/// `providerId` unchanged so cloud users keep their preferred
/// vendor (OpenAI / Anthropic / Zhipu / etc).
public var localModeProviderId: String {
isLocalEngine ? "deepseek" : providerId
}
/// Cloud engine uses `providerId`. Local engine pins DeepSeek.
public var localModeProviderId: String { "deepseek" }
private let defaults: UserDefaults
private var isSyncingProviderAPIKey = false
public init(defaults: UserDefaults? = nil) {
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
@@ -239,7 +231,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
// is empty in the suite and all subsequent reads go through the
// Keychain.
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults)
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
@@ -278,12 +270,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
self.handednessPreference = HandednessPreference.fromStored(
resolvedDefaults.string(forKey: Key.handednessPreference)
)
if resolvedDefaults.object(forKey: Key.cursorDragNavigationEnabled) == nil {
self.cursorDragNavigationEnabled = true
} else {
self.cursorDragNavigationEnabled = resolvedDefaults.bool(forKey: Key.cursorDragNavigationEnabled)
}
// v0.3.0: polish intensity. Default to `.medium` for new
// installs and upgrades; the existing `off` / `light` /
// `heavy` values are honored.
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity),
let intensity = PolishIntensity(rawValue: raw) {
self.polishIntensity = intensity
// installs; legacy `"off"` migrates to `.medium`.
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity) {
self.polishIntensity = PolishIntensity.resolve(storedRawValue: raw)
if raw == PolishIntensity.legacyOffRawValue {
resolvedDefaults.set(PolishIntensity.medium.rawValue, forKey: Key.polishIntensity)
}
} else {
self.polishIntensity = .default
}
@@ -292,17 +290,36 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
if self.engineMode == "cloud", self.modeId != "polish" {
self.modeId = "polish"
}
// DeepSeek is local-engine only never a cloud picker choice.
if self.engineMode == "cloud", self.providerId == "deepseek" {
apply(preset: LLMProvider.provider(id: "openai"))
}
}
/// Keep cloud vs local provider choices isolated when the user
/// switches engines in Settings / onboarding.
private func applyEngineModeSideEffects() {
if engineMode == "cloud", providerId == "deepseek" {
apply(preset: LLMProvider.provider(id: "openai"))
}
}
/// Read the API key from the Keychain, falling back to a one-time
/// migration from the legacy UserDefaults slot.
private static func resolveAPIKey(defaults: UserDefaults) -> String {
if let stored = Keychain.apiKey(), !stored.isEmpty {
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
return stored
}
// Migration path: old builds stored one global key under
// Keychain account "current". Move it to the active provider.
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
try? Keychain.deleteLegacyAPIKey()
return legacyKeychain
}
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
!legacy.isEmpty {
try? Keychain.setAPIKey(legacy)
try? Keychain.setAPIKey(legacy, for: providerId)
defaults.removeObject(forKey: Key.apiKeyLegacy)
return legacy
}
+48 -22
View File
@@ -45,6 +45,8 @@ public struct AppGroupStore: @unchecked Sendable {
// computed shim for source compatibility.
static let translationTargetLocaleId = "config.translationTargetLocaleId"
static let handednessPreference = "config.handednessPreference"
// Drag pads beside the mic move the caret like arrow keys.
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
// v0.3.0: polish intensity (off / light / medium / heavy).
static let polishIntensity = "config.polishIntensity"
// v0.3.0: last app context detected by the keyboard extension.
@@ -70,7 +72,7 @@ public struct AppGroupStore: @unchecked Sendable {
/// Returns "" when nothing is stored so the LLMClient can surface a
/// `noAPIKey` error rather than firing off an obviously-bad request.
public var apiKey: String {
Keychain.apiKey() ?? ""
Keychain.apiKey(for: providerId) ?? ""
}
public var model: String {
@@ -137,6 +139,15 @@ public struct AppGroupStore: @unchecked Sendable {
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
}
/// Press-and-drag pads beside the mic for four-way caret movement.
/// Defaults to `true` for new installs.
public var cursorDragNavigationEnabled: Bool {
guard defaults.object(forKey: Key.cursorDragNavigationEnabled) != nil else {
return true
}
return defaults.bool(forKey: Key.cursorDragNavigationEnabled)
}
// MARK: - Writes
public func setModeId(_ id: String) {
@@ -187,29 +198,30 @@ public struct AppGroupStore: @unchecked Sendable {
AppGroupConfigDarwin.postConfigChanged()
}
/// Whether ASR output should be sent through the cloud LLM step.
/// Cloud engine: always. Local engine: only when cloud polish is
/// enabled (translation is a sub-option of that step).
public var shouldRunCloudLLMStep: Bool {
if engineMode == "cloud" { return true }
return localModeCloudPolishEnabled
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.cursorDragNavigationEnabled)
AppGroupConfigDarwin.postConfigChanged()
}
/// Whether ASR output should be sent through the LLM polish step.
/// Both engines always run polish after ASR completes (chunked
/// pipeline stitches first). Ultra-short structure-free utterances
/// may skip the LLM inside `PolishingService`.
public var shouldRunCloudLLMStep: Bool { true }
/// Whether translate-and-polish should run (vs polish-only).
public var isTranslationEffective: Bool {
guard translationEnabled else { return false }
if engineMode == "local" { return localModeCloudPolishEnabled }
return true
translationEnabled
}
/// Whether the keyboard top-bar translation chip should render.
/// Cloud engine: always. Local engine: when cloud polish is enabled
/// (translation is a sub-option of that LLM step). Independent of
/// whether a target locale is currently selected the chip stays
/// visible so the user can pick "" or a language in-place.
public var isTranslationChipVisible: Bool {
if engineMode == "cloud" { return true }
return localModeCloudPolishEnabled
public var isTranslationChipVisible: Bool { true }
/// Cloud engine requires a provider-specific API key before the user
/// can start voice input. Local engine uses the built-in DeepSeek path.
public var isCloudAPIKeyMissingForVoiceInput: Bool {
guard engineMode == "cloud" else { return false }
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
/// Polish vs translate-and-polish for the active pipeline.
@@ -230,10 +242,14 @@ public struct AppGroupStore: @unchecked Sendable {
/// How aggressively the LLM should rewrite the ASR transcript.
/// Defaults to `medium` for new installs.
public var polishIntensity: PolishIntensity {
guard let raw = defaults.string(forKey: Key.polishIntensity),
let value = PolishIntensity(rawValue: raw)
else { return .default }
return value
guard let raw = defaults.string(forKey: Key.polishIntensity) else {
return .default
}
let resolved = PolishIntensity.resolve(storedRawValue: raw)
if raw == PolishIntensity.legacyOffRawValue {
defaults.set(resolved.rawValue, forKey: Key.polishIntensity)
}
return resolved
}
public func setPolishIntensity(_ intensity: PolishIntensity) {
@@ -296,7 +312,17 @@ public struct AppGroupStore: @unchecked Sendable {
return .empty
}
do {
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
if dictionary.entries.contains(where: { $0.source == .history }) {
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
dictionary.entries[index].source = .manual
}
dictionary.version += 1
if let migrated = try? JSONEncoder().encode(dictionary) {
defaults.set(migrated, forKey: Key.personalDictionary)
}
}
return dictionary
} catch {
#if DEBUG
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
@@ -0,0 +1,328 @@
// CursorNavigation.swift
// OSGKeyboard · Shared
//
// Pure helpers for moving the text caret from the keyboard extension.
// Horizontal moves are character-accurate. Vertical moves jump between
// *visual* lines hard `\n` breaks and soft wraps.
//
// First-principles note: a keyboard extension only sees a bounded text
// window (`documentContext{Before,After}Input`) and can only actuate via
// `adjustTextPosition(byCharacterOffset:)`. It has NO access to the host
// field's font, width, or caret rect, so soft-wrap positions are
// fundamentally unknowable and must be *estimated*. We reduce the visible
// error two ways: (1) exact handling of hard `\n`; (2) an injectable
// per-character width so the extension can feed real font metrics (killing
// the i-vs-W column drift that a fixed 1/2 table causes). The wrap width
// itself stays a calibrated estimate.
import Foundation
import CoreGraphics
public enum CursorNavigation {
/// Advance width of a single character, in an arbitrary but consistent
/// unit (points when backed by real font metrics; abstract "units" for
/// the built-in default). Must be paired with a `lineWidth` in the same
/// unit.
public typealias CharacterWidth = @Sendable (Character) -> CGFloat
// MARK: - Layout config
/// Describes how text wraps into visual lines. `lineWidth` and the values
/// returned by `widthOf` must share the same unit.
public struct VisualLineLayoutConfig: Sendable {
/// Wrap threshold: max total width of one visual line.
public let lineWidth: CGFloat
/// Per-character advance width provider.
public let widthOf: CharacterWidth
public init(
lineWidth: CGFloat,
widthOf: @escaping CharacterWidth = CursorNavigation.defaultDisplayWidth
) {
self.lineWidth = max(1, lineWidth)
self.widthOf = widthOf
}
/// Conservative default when no field width is known.
public static let fallback = VisualLineLayoutConfig(lineWidth: 44)
}
// MARK: - Public API
/// Legacy logical column (chars since last `\n`). Kept for tests.
public static func column(before: String?) -> Int {
guard let before, !before.isEmpty else { return 0 }
if let lastNewline = before.lastIndex(of: "\n") {
return before.distance(from: before.index(after: lastNewline), to: before.endIndex)
}
return before.count
}
/// Display-column offset (in `widthOf` units) on the current visual line.
public static func visualDisplayColumn(
before: String?,
after: String?,
config: VisualLineLayoutConfig
) -> CGFloat {
let text = mergedContext(before: before, after: after)
let cursor = before?.count ?? 0
let layout = VisualLineLayout(text: text, config: config)
let lineStart = layout.lineStart(containing: cursor)
return layout.width(from: lineStart, to: cursor)
}
/// One visual line up. Returns caret offset and the display column to
/// keep sticky for the rest of this vertical drag.
public static func visualLineUpOffset(
before: String?,
after: String?,
preferredDisplayColumn: CGFloat?,
config: VisualLineLayoutConfig
) -> (offset: Int, stickyColumn: CGFloat)? {
let text = mergedContext(before: before, after: after)
let cursor = before?.count ?? 0
let layout = VisualLineLayout(text: text, config: config)
guard let currentLine = layout.lineIndex(containing: cursor), currentLine > 0 else {
return nil
}
let sticky = preferredDisplayColumn
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
let previousStart = layout.lineStarts[currentLine - 1]
let previousEnd = layout.lineStarts[currentLine]
let target = layout.offset(
onLineStartingAt: previousStart,
lineEndingBefore: previousEnd,
displayColumn: sticky
)
let offset = target - cursor
guard offset != 0 else { return nil }
return (offset, sticky)
}
/// One visual line down.
public static func visualLineDownOffset(
before: String?,
after: String?,
preferredDisplayColumn: CGFloat?,
config: VisualLineLayoutConfig
) -> (offset: Int, stickyColumn: CGFloat)? {
let text = mergedContext(before: before, after: after)
let cursor = before?.count ?? 0
let layout = VisualLineLayout(text: text, config: config)
guard let currentLine = layout.lineIndex(containing: cursor) else { return nil }
guard currentLine + 1 < layout.lineStarts.count else { return nil }
let sticky = preferredDisplayColumn
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
let nextStart = layout.lineStarts[currentLine + 1]
let nextEnd = currentLine + 2 < layout.lineStarts.count
? layout.lineStarts[currentLine + 2]
: text.count
let target = layout.offset(
onLineStartingAt: nextStart,
lineEndingBefore: nextEnd,
displayColumn: sticky
)
let offset = target - cursor
guard offset != 0 else { return nil }
return (offset, sticky)
}
// MARK: - Default width table
/// Crude fallback advance width: wide scripts count double, everything
/// else single. Used by tests and when real metrics are unavailable.
public static func defaultDisplayWidth(_ character: Character) -> CGFloat {
guard let scalar = character.unicodeScalars.first else { return 1 }
if character == "\n" { return 0 }
if character == "\t" { return 4 }
if isWide(scalar) { return 2 }
return 1
}
private static func isWide(_ scalar: UnicodeScalar) -> Bool {
let value = scalar.value
return (0x1100...0x115F).contains(value) // Hangul Jamo
|| (0x2E80...0xA4CF).contains(value) // CJK radicals, symbols, bopomofo, yi
|| (0xAC00...0xD7A3).contains(value) // Hangul syllables
|| (0xF900...0xFAFF).contains(value) // CJK compatibility
|| (0xFE10...0xFE1F).contains(value) // vertical forms
|| (0xFE30...0xFE6F).contains(value) // CJK compatibility forms
|| (0xFF00...0xFF60).contains(value) // fullwidth
|| (0xFFE0...0xFFE6).contains(value) // fullwidth symbols
|| (0x20000...0x2FFFF).contains(value) // CJK extension planes
|| (0x30000...0x3FFFF).contains(value)
}
// MARK: - Internals
private static func mergedContext(before: String?, after: String?) -> String {
(before ?? "") + (after ?? "")
}
// MARK: - Visual line layout
struct VisualLineLayout {
let text: String
let widthOf: CharacterWidth
let lineStarts: [Int]
init(text: String, config: VisualLineLayoutConfig) {
self.text = text
self.widthOf = config.widthOf
self.lineStarts = Self.computeLineStarts(
in: text,
maxWidth: config.lineWidth,
widthOf: config.widthOf
)
}
func lineIndex(containing offset: Int) -> Int? {
guard !lineStarts.isEmpty else { return nil }
for index in lineStarts.indices.reversed() where offset >= lineStarts[index] {
return index
}
return nil
}
func lineStart(containing offset: Int) -> Int {
lineIndex(containing: offset).map { lineStarts[$0] } ?? 0
}
func width(from start: Int, to end: Int) -> CGFloat {
guard start < end, end <= text.count else { return 0 }
let startIndex = text.index(text.startIndex, offsetBy: start)
let endIndex = text.index(text.startIndex, offsetBy: end)
var total: CGFloat = 0
var index = startIndex
while index < endIndex {
total += widthOf(text[index])
index = text.index(after: index)
}
return total
}
func offset(
onLineStartingAt lineStart: Int,
lineEndingBefore lineEnd: Int,
displayColumn: CGFloat
) -> Int {
guard lineStart <= lineEnd, lineEnd <= text.count else { return lineStart }
let startIndex = text.index(text.startIndex, offsetBy: lineStart)
let endIndex = text.index(text.startIndex, offsetBy: lineEnd)
var total: CGFloat = 0
var index = startIndex
while index < endIndex {
let advance = widthOf(text[index])
if total + advance > displayColumn { break }
total += advance
index = text.index(after: index)
}
return text.distance(from: text.startIndex, to: index)
}
private static func computeLineStarts(
in text: String,
maxWidth: CGFloat,
widthOf: CharacterWidth
) -> [Int] {
guard !text.isEmpty else { return [0] }
var starts: [Int] = [0]
var lineWidth: CGFloat = 0
var lineStart = text.startIndex
var lastBreak: String.Index?
var index = text.startIndex
while index < text.endIndex {
let character = text[index]
if character == "\n" {
let next = text.index(after: index)
let nextOffset = text.distance(from: text.startIndex, to: next)
if starts.last != nextOffset {
starts.append(nextOffset)
}
lineStart = next
lineWidth = 0
lastBreak = nil
index = next
continue
}
let advance = widthOf(character)
if character == " " || character == "\t" {
lastBreak = index
}
if lineWidth + advance > maxWidth, index > lineStart {
let breakIndex: String.Index
if let lastBreak, lastBreak > lineStart {
breakIndex = text.index(after: lastBreak)
} else {
breakIndex = index
}
let breakOffset = text.distance(from: text.startIndex, to: breakIndex)
if starts.last != breakOffset {
starts.append(breakOffset)
}
lineStart = breakIndex
lineWidth = 0
lastBreak = nil
if breakIndex == index {
lineWidth = advance
index = text.index(after: index)
}
continue
}
lineWidth += advance
index = text.index(after: index)
}
return starts
}
}
}
#if canImport(UIKit)
import UIKit
/// Real-font per-character advance widths (in points) for cursor visual-line
/// navigation. Caches measurements so repeated drag samples are cheap.
///
/// Absolute values assume a ~17 pt body font; only the *ratios* between
/// glyphs (and between a glyph and the field width) matter for column
/// fidelity, so a reference font is sufficient to eliminate the fixed-width
/// column drift.
///
/// Not actor-isolated on purpose: the width closure is invoked synchronously
/// from the nonisolated `CursorNavigation` layout code. A lock guards the
/// cache so `@unchecked Sendable` is safe.
public final class CursorGlyphMetrics: @unchecked Sendable {
public static let shared = CursorGlyphMetrics()
private let font = UIFont.systemFont(ofSize: 17)
private let lock = NSLock()
private var cache: [Character: CGFloat] = [:]
public init() {}
public func width(of character: Character) -> CGFloat {
if character == "\n" { return 0 }
lock.lock()
defer { lock.unlock() }
if let cached = cache[character] { return cached }
let measured = (String(character) as NSString)
.size(withAttributes: [.font: font])
.width
let width = measured > 0 ? measured : font.pointSize * 0.5
cache[character] = width
return width
}
}
#endif
+21 -12
View File
@@ -71,6 +71,11 @@ public final class KeyboardState: ObservableObject {
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
/// Whether the host app's Flow voice session is currently valid.
@Published public var flowSessionActive: Bool = false
/// When true, the mic is intentionally disabled (e.g. cloud engine
/// selected but the provider-specific API key is missing).
@Published public var micDisabled: Bool = false
/// One-line helper shown above the mic while `micDisabled == true`.
@Published public var micDisabledHint: String = ""
/// "local" on-device ASR only. "cloud" ASR + LLM polish.
@Published public var engineMode: String = "cloud"
/// Which on-device ASR engine to use when `engineMode == "local"`.
@@ -98,24 +103,23 @@ public final class KeyboardState: ObservableObject {
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
/// state on first install.
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
/// v0.2.0: mirrored from App Group local engine runs the cloud
/// LLM step only when this is `true`.
@Published public var localModeCloudPolishEnabled: Bool = false
/// v0.2.0: mirrored from App Group kept for source compatibility.
/// Local engine always runs built-in polish; the flag is ignored.
@Published public var localModeCloudPolishEnabled: Bool = true
/// Mirrored from App Group swaps delete / return on the bottom row.
@Published public var handednessPreference: HandednessPreference = .left
/// Whether translate-and-polish is actually armed for the current
/// engine (local requires cloud polish + a target locale).
/// Press-and-drag pads beside the mic for four-way caret movement.
@Published public var cursorDragNavigationEnabled: Bool = true
/// `true` while a cursor-drag pad is being pressed drives the hint
/// shown above the mic.
@Published public var cursorDragActive: Bool = false
/// Whether translate-and-polish is armed for the current engine.
public var isTranslationEffective: Bool {
guard translationEnabled else { return false }
if isLocalEngine { return localModeCloudPolishEnabled }
return true
translationEnabled
}
/// Whether the keyboard top-bar translation chip should render.
public var isTranslationChipVisible: Bool {
if isLocalEngine { return localModeCloudPolishEnabled }
return true
}
public var isTranslationChipVisible: Bool { true }
/// Convenience shorthand used by the pipeline and views.
public var isLocalEngine: Bool { engineMode == "local" }
@@ -168,6 +172,11 @@ public final class KeyboardState: ObservableObject {
public var insertNewline: () -> Void = {}
public var insertSpace: () -> Void = {}
public var deleteBackward: () -> Void = {}
public var moveCursorHorizontal: (Int) -> Void = { _ in }
public var moveCursorVertical: (Int) -> Void = { _ in }
/// Cursor-drag pad press lifecycle updates `cursorDragActive` and
/// lets the view controller reset vertical-navigation stickiness.
public var setCursorDragActive: (Bool) -> Void = { _ in }
// MARK: - Preview helpers (DEBUG only)
+61 -8
View File
@@ -39,18 +39,25 @@ public enum Keychain: @unchecked Sendable {
}
private static let service = "com.osgkeyboard.apikey"
private static let account = "current"
private static let legacyAccount = "current"
private static let defaultProviderId = "openai"
private static func account(for providerId: String) -> String {
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
return "provider.\(normalized)"
}
// MARK: - Read
/// Read the stored API key. Returns `nil` when nothing is stored,
/// or when the underlying call returns a non-success status we can't
/// usefully surface (e.g. transient `errSecInteractionNotAllowed`).
public static func apiKey() -> String? {
public static func apiKey(for providerId: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccount as String: account(for: providerId),
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
@@ -73,21 +80,45 @@ public enum Keychain: @unchecked Sendable {
}
}
/// Backward-compatible shorthand for the default cloud provider.
public static func apiKey() -> String? {
apiKey(for: defaultProviderId)
}
/// Legacy account used by older builds before provider-scoped keys.
/// New code should avoid this and use `apiKey(for:)`.
public static func legacyAPIKey() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let str = String(data: data, encoding: .utf8)
else { return nil }
return str
}
// MARK: - Write
/// Store (or update) the API key. An empty string deletes the entry,
/// so clearing the field in the UI removes the key from the Keychain
/// rather than leaving an empty-string placeholder.
public static func setAPIKey(_ key: String) throws {
public static func setAPIKey(_ key: String, for providerId: String) throws {
if key.isEmpty {
try deleteAPIKey()
try deleteAPIKey(for: providerId)
return
}
let data = Data(key.utf8)
let baseQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccount as String: account(for: providerId),
]
// Try update first covers the common path where the key already
// exists (every settings edit after the first).
@@ -112,13 +143,18 @@ public enum Keychain: @unchecked Sendable {
}
}
/// Backward-compatible shorthand for the default cloud provider.
public static func setAPIKey(_ key: String) throws {
try setAPIKey(key, for: defaultProviderId)
}
// MARK: - Delete
public static func deleteAPIKey() throws {
public static func deleteAPIKey(for providerId: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecAttrAccount as String: account(for: providerId),
]
let status = SecItemDelete(query as CFDictionary)
// `errSecItemNotFound` is success-from-the-user's-perspective the
@@ -127,4 +163,21 @@ public enum Keychain: @unchecked Sendable {
throw KeychainError.unexpectedStatus(status)
}
}
/// Backward-compatible shorthand for the default cloud provider.
public static func deleteAPIKey() throws {
try deleteAPIKey(for: defaultProviderId)
}
public static func deleteLegacyAPIKey() throws {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: legacyAccount,
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw KeychainError.unexpectedStatus(status)
}
}
}
+19 -7
View File
@@ -36,15 +36,25 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
}
public protocol LLMClient: Sendable {
func polish(_ text: String, systemPrompt: String) async throws -> String
/// Polish `text` with `systemPrompt`. `timeout` overrides the
/// per-request HTTP timeout for this call; when `nil` the client's
/// `requestTimeout` baseline is used. Long transcripts must pass a
/// larger, length-scaled timeout so the HTTP request is not cut off
/// mid-generation (see `PolishingService.effectiveTimeout`).
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String
/// Single source of truth for the upper bound on a single LLM HTTP
/// round-trip. Both the `URLRequest` we send and any wrapping
/// timeout-style race (e.g. `PolishingService`'s `withThrowingTaskGroup`)
/// must read from this property so the two never disagree.
/// Baseline upper bound for a single LLM HTTP round-trip when no
/// per-request `timeout` is supplied.
var requestTimeout: TimeInterval { get }
}
public extension LLMClient {
/// Convenience overload that uses the baseline `requestTimeout`.
func polish(_ text: String, systemPrompt: String) async throws -> String {
try await polish(text, systemPrompt: systemPrompt, timeout: nil)
}
}
// MARK: - OpenAI-compatible implementation
public struct OpenAICompatibleClient: LLMClient {
@@ -71,7 +81,7 @@ public struct OpenAICompatibleClient: LLMClient {
self.session = session
}
public func polish(_ text: String, systemPrompt: String) async throws -> String {
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
let urlString = baseURL.hasSuffix("/")
@@ -93,7 +103,9 @@ public struct OpenAICompatibleClient: LLMClient {
req.httpMethod = "POST"
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
req.timeoutInterval = requestTimeout
// Per-request timeout scales with transcript length; fall back to
// the baseline when the caller does not supply one.
req.timeoutInterval = timeout ?? requestTimeout
let encoder = JSONEncoder()
req.httpBody = try encoder.encode(request)
+151 -131
View File
@@ -10,24 +10,15 @@
// English dictation while halving the network round-trip.
//
// Engine matrix:
// - `engineMode == "cloud"` always polish
// - `engineMode == "local"`,
// cloud polish disabled ASR-only, return raw.
// - `engineMode == "local"`,
// cloud polish enabled DeepSeek LLM step (polish or translate).
// Translation uses `.translate` + `TranslationPrompt`; polish uses
// the default system prompt. Missing preconfigured DeepSeek key
// throws `missingAPIKey` and callers deliver raw + warning.
// - `polishIntensity == .off` ASR-only, return raw,
// regardless of engine mode
// - Missing API key return raw + throw
// `.missingAPIKey` so the caller can show the "fill in your key"
// hint inline
// - `engineMode == "cloud"` on-device ASR, then user's cloud LLM
// - `engineMode == "local"` on-device ASR, then built-in DeepSeek
// - Ultra-short, structure-free utterances skip the LLM entirely
// - Cloud without API key raw + `.missingAPIKey` warning
// - Local without build key raw + `.missingAPIKey` warning
//
// Caller-supplied `PolishContext` carries the per-call signals:
// - `appContext` code / email / chat / document / unknown
// - `intensity` off / light / medium / heavy (per-call
// override; default is the user-configured value)
// - `intensity` light / medium / heavy (per-call override)
// - `precedingText` optional tail of the cursor's preceding text
// for reference resolution
//
@@ -63,11 +54,11 @@ public actor PolishingService {
/// one from `store.makeClient()` per call.
private let injectedClient: LLMClient?
/// Default `timeout` is `LLMClient.requestTimeout + 1` second so the
/// safety-net `withThrowingTaskGroup` never wins the race against
/// the URL request itself; if the request times out cleanly the
/// network error reaches us first. The +1 is the single point of
/// slack between the two clocks keep it here, not in `LLMClient`.
/// `timeout` is the baseline (shortest) per-request HTTP timeout,
/// used as the floor for `effectiveTimeout(for:)`. It defaults to the
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its
/// own slack on top of the length-scaled budget in `polishRemote`, so
/// no `+1` is baked in here.
public init(
store: AppGroupStore = AppGroupStore(),
client: LLMClient? = nil,
@@ -75,10 +66,10 @@ public actor PolishingService {
) {
self.store = store
self.injectedClient = client
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
}
/// v0.3.0: context-aware polish entry point. The optional
/// v0.3.0: context-aware polish entry point. The optional
/// `PolishContext` carries per-call signals (app context,
/// intensity, preceding text). Translation is a separate concept
/// (see `mode` below) so callers wanting the v0.2.1 translate
@@ -94,42 +85,38 @@ public actor PolishingService {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
// Resolve per-call context: per-call override wins over the
// user-configured App Group value.
let resolvedContext = resolveContext(override: context)
// "off" intensity never calls the LLM, regardless of engine
// or mode. This lets users opt into "transcribe only" with
// one tap without having to flip the engine mode or pick a
// translation off-locale.
if resolvedContext.intensity == .off {
return trimmed
// Ultra-short, structure-free inputs skip the LLM to save
// latency (e.g. "", "OK", "").
if mode == .polish,
systemPrompt == nil || systemPrompt?.isEmpty == true,
TranscriptPostProcessor.shouldSkipLLM(for: trimmed) {
return TranscriptPostProcessor.localClean(trimmed)
}
// Local engine + cloud-polish-off: pure ASR, no LLM.
if store.engineMode == "local" {
guard store.shouldRunCloudLLMStep else { return trimmed }
} else {
// Cloud engine needs an API key.
if store.engineMode == "cloud", injectedClient == nil {
guard !store.apiKey.isEmpty else {
throw PolishError.missingAPIKey
}
}
return try await polishRemote(
let llmResult = try await polishRemote(
trimmed,
mode: mode,
systemPrompt: systemPrompt,
providerIdOverride: providerIdOverride,
context: resolvedContext
)
// Translation and custom prompts bypass the polish post-processor.
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
return llmResult
}
return TranscriptPostProcessor.process(original: trimmed, llmOutput: llmResult)
}
/// Build the final `PolishContext` for this call. Per-call
/// overrides take precedence; otherwise we read the user-configured
/// values out of the App Group (so the keyboard extension's
/// `PolishingService` instance does not need to know about
/// `ProviderConfig`).
private func resolveContext(override: PolishContext?) -> PolishContext {
guard let override else {
return PolishContext(
@@ -137,10 +124,6 @@ public actor PolishingService {
intensity: store.polishIntensity
)
}
// If the override leaves a field at its default-when-nil
// value, fall back to the App Group value. Today every
// `PolishContext` field is non-optional so this branch
// simply forwards; kept for future-proofing.
return override
}
@@ -151,12 +134,10 @@ public actor PolishingService {
providerIdOverride: String? = nil,
context: PolishContext
) async throws -> String {
// v0.2.1 follow-up: when the caller pins a provider id (the
// local engine pins DeepSeek) we still want to honor the
// injected test client, but we have to re-derive the
// preset/baseURL/model/apiKey quartet from the *override* so
// the injected client gets the right values when it's nil.
let effectiveProviderId = providerIdOverride ?? store.providerId
let effectiveProviderId = Self.resolvedProviderId(
store: store,
providerIdOverride: providerIdOverride
)
let client: LLMClient
if let injectedClient {
client = injectedClient
@@ -169,32 +150,27 @@ public actor PolishingService {
)
let apiKey: String
if effectiveProviderId == "deepseek" {
let preconfigured = PreconfiguredKeys.deepseek
if preconfigured == "TODO_FILL_LATER_DEEPSEEK_KEY" {
// Placeholder still in place refuse the round-
// trip so the UI can surface a "build not
// configured" hint instead of a 401.
guard PreconfiguredKeys.isDeepseekConfigured else {
throw PolishError.missingAPIKey
}
apiKey = preconfigured
apiKey = PreconfiguredKeys.deepseek
} else {
apiKey = store.apiKey
}
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
}
// Polish-mode callers (and translation-mode callers that
// haven't supplied an explicit override) get the new
// "intelligent" prompt that uses `PolishContext.appContext`,
// `intensity`, and the personal dictionary. Translation-mode
// callers keep the v0.2.1 `TranslationPrompt` path so the
// translate-and-polish output contract doesn't change.
let prompt: String
if let override = systemPrompt, !override.isEmpty {
prompt = override
} else {
switch mode {
case .polish:
prompt = buildPrompt(for: trimmed, context: context)
prompt = buildPrompt(
for: trimmed,
context: context,
providerId: effectiveProviderId
)
case .translate(let targetLocaleId):
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
prompt = TranslationPrompt.make(
@@ -205,13 +181,17 @@ public actor PolishingService {
}
}
let budget = effectiveTimeout(for: trimmed)
// The HTTP request itself uses `budget`; the safety-net timer is
// given a small slack on top so a clean URL timeout surfaces its
// (more specific) transport error before the race fires.
let safetyNet = budget + 2
return try await withThrowingTaskGroup(of: String.self) { group in
group.addTask {
try await client.polish(trimmed, systemPrompt: prompt)
try await client.polish(trimmed, systemPrompt: prompt, timeout: budget)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(budget * 1_000_000_000))
try await Task.sleep(nanoseconds: UInt64(safetyNet * 1_000_000_000))
throw PolishError.timeout
}
let result = try await group.next()!
@@ -220,91 +200,120 @@ public actor PolishingService {
}
}
/// Build the one-step "intelligent" prompt. The structure is:
/// 1. Role
/// 2. Three numbered tasks (correction, polish, style)
/// 3. Hard rules (do-not-modify list, length cap, short-circuit)
/// 4. User dictionary block (if any)
/// 5. Context + intensity guidelines
/// 6. Optional preceding text
/// 7. The transcript to process
/// 8. Output contract
///
/// The Chinese / English split mirrors `shouldUseChineseGuidance` so
/// the polish step stays in the provider's strongest language.
internal func buildPrompt(for text: String, context: PolishContext) -> String {
/// Shared output contract injected into every polish prompt.
internal static func globalOutputContract(useChinese: Bool) -> String {
if useChinese {
return """
##
1. ** emoji** emoji emoji emoji
2. ****
3. ****
- /// `1. `
- ////
-
-
4. ****
-
-
· 2:00 / 20 / 3:00
· 123 `1. `
- /
5. ****
6. **** ASR
7. AI
8.
"""
} else {
return """
## Global output contract (mandatory at every intensity highest priority)
1. **No new emojis**: if the original has none, output must have none; preserve originals only.
2. **Restore proper punctuation**: commas, periods, question marks; break run-on speech into sentences.
3. **Content-triggered structure** (every intensity):
- "first point / second / step one / one is two is three" numbered `1. ` list with line breaks
- "firstly / secondly / finally / on the other hand" paragraph breaks, not forced numbering
- todos, meeting notes, multiple questions, long multi-clause speech semantic paragraphs
4. **Judge numbers by context** (important):
- Meaningful numbers (prices, dates, quantities, times, phone numbers, versions) keep unchanged.
- But spoken ordinals are often misrecognized as digits/times; use context to restore and listify:
· after a "first point", a following "2:00 / point 2 / two oh oh" is likely "second point", "3:00" is "third point"
· "1, 2, 3" or "one, two, three" in an enumerating context are ordinals convert to a `1. ` list
- Decide by whether the context is enumerating; do not mechanically preserve a misheard number.
5. **Conservative rewrite**: prefer punctuation over rewording; prefer breaks over rewriting; minimal changes.
6. **Do not** alter person names, places, or proper nouns unless clearly misrecognized.
7. Output language must match the input; do not translate or expand into marketing copy.
8. Output the final text only: no explanation, no quotes, no preamble.
"""
}
}
internal func buildPrompt(
for text: String,
context: PolishContext,
providerId: String
) -> String {
let dictionary = store.personalDictionary
let dictionaryBlock = dictionary.promptFragment()
let contextGuideline = context.appContext.polishGuideline
let intensityGuideline = context.intensity.promptGuideline
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
let precedingBlock = context.precedingForPrompt
.map { "上文(仅供参考,**不要**改写):\n\($0)\n" } ?? ""
let useChinese = shouldUseChineseGuidance(providerId: store.providerId)
.map {
"""
## //********
\($0)
"""
} ?? ""
let useChinese = shouldUseChineseGuidance(providerId: providerId)
if useChinese {
return """
ASR
\(contract)
## 1
-
-
- ****
## 2
- ok
-
-
-
## 2
-
-
-
## 3
## 3
\(context.appContext.rawValue)
\(contextGuideline)
\(intensityGuideline)
##
1. ****
2.
3.
4. 8 15
5.
\(dictionaryBlock.isEmpty ? "" : "## 用户词典(必须原样保留,禁止改写)\n\(dictionaryBlock)\n")
\(precedingBlock)
##
\(precedingBlock)##
\(text)
****
"""
} else {
return """
You are the post-processing engine of a voice-input keyboard. Complete three tasks in one pass:
You are the post-processing engine of a voice-input keyboard. In one pass: fix ASR errors, restore punctuation, structure content, and polish per intensity.
\(contract)
## Task 1: Correction
- Fix obvious speech-recognition errors (homophones, near-misses, missing/extra characters).
- Correct proper nouns, English terms, and technical identifiers (see the user dictionary below).
- **Never** alter numbers, person names, or place names unless clearly wrong.
## Task 2: Polish
- Remove redundant filler words (um, uh, like, you know, basically).
- Remove duplicated fragments the speaker self-corrected.
- Adjust obviously broken word order.
- Add appropriate punctuation and capitalization.
## Task 2: Punctuation and structure
- Restore proper punctuation and sentence boundaries.
- Detect oral lists, steps, enumerated points, meeting-note structure and format them.
- Break long speech into semantic paragraphs.
## Task 3: Style adaptation
## Task 3: Polish (per intensity)
Current input context: \(context.appContext.rawValue)
Style guideline: \(contextGuideline)
Polish intensity: \(intensityGuideline)
## Hard rules
1. Minimum-change principle: do not rewrite parts the user already said clearly.
2. Preserve the speaker's voice and intent.
3. Never add information that is not in the original.
4. Short inputs ( 15 English words or 8 CJK characters) must be returned verbatim.
5. Output language must match the input language.
\(dictionaryBlock.isEmpty ? "" : "## User dictionary (must be preserved verbatim)\n\(dictionaryBlock)\n")
\(precedingBlock)
## Original transcript
\(precedingBlock)## Original transcript
\(text)
Output the processed text directly. **No explanation, no quotes, no preamble.**
@@ -312,7 +321,6 @@ public actor PolishingService {
}
}
/// Chinese-native LLM providers get a Chinese prompt, English ones get English.
private func shouldUseChineseGuidance(providerId: String) -> Bool {
switch providerId {
case "zhipu", "moonshot", "qwen", "deepseek":
@@ -322,19 +330,35 @@ public actor PolishingService {
}
}
/// Scale polish budget with transcript length (3-minute Flow utterances).
private func effectiveTimeout(for text: String) -> TimeInterval {
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
/// Per-request HTTP timeout, scaled with transcript length. This is
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
/// dictations (which generate long, listified, multi-paragraph output)
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
/// ~10 s per 100 characters, capped at 120 s.
///
/// Previously this value was computed but only used for the safety-net
/// timer while the URLRequest stayed pinned at 15 s the scaling was
/// dead code and long transcripts timed out, falling back to the raw
/// (unpolished, unsegmented) ASR text.
internal func effectiveTimeout(for text: String) -> TimeInterval {
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
return min(max(scaled, timeout), 120)
}
/// Picks base URL + model for one remote polish call.
///
/// When `providerIdOverride` is set (local engine pins DeepSeek),
/// always use that preset's defaults so cloud-engine settings
/// (e.g. Qwen base URL saved while testing cloud mode) are not
/// mixed with the pinned provider's API key. Cloud engine passes
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
internal static func resolvedProviderId(
store: AppGroupStore,
providerIdOverride: String?
) -> String {
if let providerIdOverride {
return providerIdOverride
}
if store.engineMode == "local" {
return "deepseek"
}
let id = store.providerId
return id == "deepseek" ? "openai" : id
}
internal static func resolveLLMEndpoint(
store: AppGroupStore,
preset: LLMProvider,
@@ -344,10 +368,6 @@ public actor PolishingService {
return (preset.defaultBaseURL, preset.defaultModel)
}
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
// Pre-existing typo fix: the user-overridden `store.model`
// path was returning `preset.defaultModel` on both branches,
// silently ignoring the user's custom model field. Restore
// the asymmetry so the user override actually wins.
let model = store.model.isEmpty ? preset.defaultModel : store.model
return (baseURL, model)
}
@@ -361,7 +381,7 @@ extension PolishingService.PolishError: LocalizedError {
case .timeout:
return "LLM polish timed out."
case .missingAPIKey:
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
return "Missing API key (cloud: Settings API key; local: build configuration)."
}
}
}
@@ -0,0 +1,14 @@
// PreconfiguredKeys.local.swift.example
// Copy to PreconfiguredKeys.local.swift (gitignored) before building.
// `./Scripts/generate-xcodeproj.sh` creates PreconfiguredKeys.local.swift
// from this file automatically when it is missing.
//
// The DeepSeek key is used ONLY by the local engine's built-in polish step.
// Do not commit the real key — keep it in PreconfiguredKeys.local.swift on
// your machine only.
import Foundation
enum PreconfiguredKeysLocal {
static let deepseek = "TODO_FILL_LATER_DEEPSEEK_KEY"
}
@@ -1,19 +1,14 @@
// PreconfiguredKeys.swift
// OSGKeyboard · Shared
//
// v0.2.1 follow-up: preconfigured API keys for built-in cloud providers
// the keyboard ships with out of the box. Today the only one is DeepSeek
// the local engine's default polish vendor (see
// `ProviderConfig.localModeProviderId`). Future builds may pre-fill
// additional providers as we harden them.
// Built-in API keys for engine-specific polish vendors. The local engine
// pins DeepSeek; the actual key lives in `PreconfiguredKeys.local.swift`
// (gitignored) so it never ships in the public repo.
//
// These constants live in source so a developer building from the repo
// can swap in their own key once and have every Debug / TestFlight build
// "just work" without round-tripping the Keychain settings UI.
//
// IMPORTANT: Replace the placeholder string with a real key before
// shipping a build. The DEBUG assert below catches the placeholder at
// launch so nobody accidentally publishes an "always 401" build.
// `./Scripts/generate-xcodeproj.sh` copies
// `PreconfiguredKeys.local.swift.example` `PreconfiguredKeys.local.swift`
// on first run. Replace the placeholder in the local file before
// distributing a build that uses the local engine.
import Foundation
@@ -22,29 +17,33 @@ public enum PreconfiguredKeys {
/// this is treated as "configured".
private static let placeholder = "TODO_FILL_LATER_DEEPSEEK_KEY"
/// Preconfigured DeepSeek API key. Replace `placeholder` with a
/// real key in `Sources/.../PreconfiguredKeys.swift` before
/// distributing a build.
public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY"
/// DeepSeek API key for the local engine's built-in polish step.
public static var deepseek: String {
PreconfiguredKeysLocal.deepseek
}
public static var isDeepseekConfigured: Bool {
deepseek != placeholder && !deepseek.isEmpty
}
#if DEBUG
/// Forces a lazy init at app launch in DEBUG builds so the assert
/// below fires immediately when somebody forgets to swap the
/// placeholder. The boolean is intentionally unused at runtime
/// it's a tripwire.
public static let isDeepseekConfigured: Bool = {
public static let debugDeepseekTripwire: Bool = {
assert(
deepseek != placeholder,
"DeepSeek preconfigured key not filled — replace TODO_FILL_LATER_DEEPSEEK_KEY in PreconfiguredKeys.swift before building"
isDeepseekConfigured,
"DeepSeek preconfigured key not filled — copy PreconfiguredKeys.local.swift.example to PreconfiguredKeys.local.swift and set your key"
)
return deepseek != placeholder
return isDeepseekConfigured
}()
/// Touch the tripwire so the assert fires at launch rather than
/// only the first time the local engine actually tries to polish.
/// Called from app startup; safe to invoke multiple times.
public static func assertProductionReadinessAtLaunch() {
_ = isDeepseekConfigured
_ = debugDeepseekTripwire
}
#endif
}
@@ -0,0 +1,296 @@
// TranscriptPostProcessor.swift
// OSGKeyboard · Shared
//
// Deterministic post-processing after the LLM polish step. The LLM
// handles semantic punctuation and structure; this module enforces
// hard output constraints (emoji ban, list normalization, quality
// gate) and decides when ultra-short inputs can skip the LLM entirely.
import Foundation
public enum TranscriptPostProcessor: Sendable {
/// Result of the quality gate applied to LLM output.
public enum GateDecision: Equatable, Sendable {
case accept(String)
case fallback(String)
}
// MARK: - Short-circuit gate (skip LLM)
/// Returns `true` when the transcript is short enough and lacks
/// structural signals so calling the LLM would add latency without
/// meaningful benefit (e.g. "", "OK", "").
public static func shouldSkipLLM(for text: String) -> Bool {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if hasStructureSignal(in: trimmed) { return false }
let cjkCount = trimmed.unicodeScalars.filter(isCJKScalar).count
if cjkCount > 0 {
// e.g. , , ,
return trimmed.count <= 4 && cjkCount <= 4
}
// e.g. OK, yes, thanks single short token only
let words = trimmed.split(whereSeparator: { $0.isWhitespace })
return words.count == 1 && trimmed.count <= 10
}
/// Local-only cleanup when the LLM is skipped. Keeps the speaker's
/// words verbatim no punctuation invention beyond trimming.
public static func localClean(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Post-LLM pipeline
/// Apply deterministic cleanup and quality gate to LLM output.
public static func process(original: String, llmOutput: String) -> String {
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
let decision = qualityGate(original: trimmedOriginal, candidate: llmOutput)
switch decision {
case .accept(let text):
return text
case .fallback(let text):
return text
}
}
/// Quality gate: clean the LLM output deterministically.
///
/// Design note: earlier revisions reverted to the *raw ASR*
/// transcript when numbers changed or the text grew "too much".
/// That was wrong listifying and correcting ASR mis-hearings
/// (e.g. "2:00" "") legitimately change the number set,
/// so the heuristic threw away good output and re-inserted the raw,
/// mis-heard transcript (the worst possible text). We now only fall
/// back when the model returned genuinely unusable output (empty, or
/// pure explanation), and even then we prefer a cleaned candidate
/// over the raw transcript.
public static func qualityGate(original: String, candidate: String) -> GateDecision {
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
if text.isEmpty {
return .fallback(localClean(original))
}
text = stripExplanatoryPrefix(from: text)
text = unwrapSurroundingQuotes(text)
text = stripAddedEmojis(original: original, output: text)
text = repairMidSentenceLineBreaks(text)
text = normalizeWhitespaceAndPunctuation(text)
text = normalizeNumberedLists(text)
// If cleanup emptied the candidate (e.g. it was only an
// explanatory prefix), fall back to the trimmed original rather
// than the raw ASR that is still the least-bad option here.
if text.isEmpty {
return .fallback(localClean(original))
}
return .accept(text)
}
// MARK: - Structure detection
/// Whether the transcript contains oral enumeration / section cues.
public static func hasStructureSignal(in text: String) -> Bool {
let patterns = [
#"第[一二三四五六七八九十\d]+[点个条段步部分]"#,
#"步骤[一二三四五六七八九十\d]+"#,
#"[一二三四五六七八九十]+是"#,
#"首先|其次|再次|最后|另外|再者|一方面|另一方面"#,
#"\b(first|second|third|fourth|fifth|finally|next|another)\b"#,
#"\b(step\s*(one|two|three|four|five|\d+))\b"#,
#"point\s*(one|two|three|four|five|\d+)"#,
]
for pattern in patterns {
if text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil {
return true
}
}
return false
}
// MARK: - Emoji
/// Remove emojis from output when the original had none; otherwise
/// keep only emojis that appeared in the original.
public static func stripAddedEmojis(original: String, output: String) -> String {
let originalEmojis = Set(extractEmojis(from: original))
if originalEmojis.isEmpty {
return removeAllEmojis(from: output)
}
return String(output.unicodeScalars.filter { scalar in
if isEmojiScalar(scalar) {
return originalEmojis.contains(String(scalar))
}
return true
})
}
// MARK: - List normalization
/// Matches a line that begins with any list marker we recognize
/// (bullet, arabic number, X, X).
static let listLinePattern =
#"^\s*(?:[-*•]|\d+[.))、]|第[一二三四五六七八九十\d]+[点.))、]|步骤[一二三四五六七八九十\d]+[.))、]?)\s+"#
/// Whether a line is a list item.
static func isListLine(_ text: String) -> Bool {
text.range(of: listLinePattern, options: .regularExpression) != nil
}
/// Normalize heterogeneous numbered-list markers to `1. ` style.
public static func normalizeNumberedLists(_ text: String) -> String {
var lines = text.components(separatedBy: .newlines)
var listIndex = 0
var inList = false
for i in lines.indices {
let line = lines[i]
guard let range = line.range(of: listLinePattern, options: .regularExpression) else {
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
inList = false
listIndex = 0
}
continue
}
let content = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)
if !inList { listIndex = 0 }
listIndex += 1
inList = true
lines[i] = "\(listIndex). \(content)"
}
return lines.joined(separator: "\n")
}
// MARK: - Mid-sentence line-break repair
/// Join line breaks that split a sentence. A newline is kept only
/// when it is a paragraph break (blank line), a list boundary, or
/// the previous line ends with a sentence terminator. Otherwise the
/// break is treated as an ASR chunk-stitch artifact (e.g.
/// "\n") and merged back into one line.
public static func repairMidSentenceLineBreaks(_ text: String) -> String {
let lines = text.components(separatedBy: "\n")
guard lines.count > 1 else { return text }
var out: [String] = []
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard let last = out.last else {
out.append(line)
continue
}
let prevTrimmed = last.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || prevTrimmed.isEmpty
|| isListLine(trimmed) || isListLine(prevTrimmed)
|| endsWithSentenceTerminator(prevTrimmed) {
out.append(line)
continue
}
out[out.count - 1] = prevTrimmed + joinGlue(prev: prevTrimmed, next: trimmed) + trimmed
}
return out.joined(separator: "\n")
}
// MARK: - Whitespace / punctuation cleanup
public static func normalizeWhitespaceAndPunctuation(_ text: String) -> String {
var result = text
// Collapse 3+ newlines to 2.
while result.contains("\n\n\n") {
result = result.replacingOccurrences(of: "\n\n\n", with: "\n\n")
}
// Collapse duplicate Chinese / Western punctuation.
let dupPairs = [
("。。", ""), (",,", ""), ("??", ""), ("!!", ""),
("..", "."), (",,", ","), ("??", "?"), ("!!", "!"),
]
for (dup, single) in dupPairs {
while result.contains(dup) {
result = result.replacingOccurrences(of: dup, with: single)
}
}
return result.trimmingCharacters(in: .whitespacesAndNewlines)
}
// MARK: - Prefix / quote cleanup
public static func stripExplanatoryPrefix(from text: String) -> String {
let prefixes = [
"以下是", "处理后", "处理后的文本", "输出如下", "结果如下",
"Here is", "Here's", "Output:", "Result:", "Processed text:",
]
var result = text
for prefix in prefixes {
if result.hasPrefix(prefix) {
result = String(result.dropFirst(prefix.count))
.trimmingCharacters(in: .whitespacesAndNewlines)
if result.hasPrefix(":") || result.hasPrefix("") {
result = String(result.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}
return result
}
public static func unwrapSurroundingQuotes(_ text: String) -> String {
guard text.count >= 2 else { return text }
let pairs: [(Character, Character)] = [("\"", "\""), ("'", "'"), ("", ""), ("", "")]
for (open, close) in pairs {
if text.first == open, text.last == close {
return String(text.dropFirst().dropLast())
}
}
return text
}
// MARK: - Private helpers
private static func endsWithSentenceTerminator(_ text: String) -> Bool {
guard let last = text.unicodeScalars.last else { return false }
let terminators: Set<Unicode.Scalar> = [
"", "", "", "", "!", "?", ".", ";", "", "", ":",
]
return terminators.contains(last)
}
/// Decide the glue between two merged fragments: a space only when
/// both sides are ASCII alphanumeric (English words); nothing for CJK.
private static func joinGlue(prev: String, next: String) -> String {
guard let p = prev.unicodeScalars.last, let n = next.unicodeScalars.first else { return "" }
let alphanumerics = CharacterSet.alphanumerics
let pAscii = p.isASCII && alphanumerics.contains(p)
let nAscii = n.isASCII && alphanumerics.contains(n)
return (pAscii && nAscii) ? " " : ""
}
private static func extractEmojis(from text: String) -> [String] {
text.unicodeScalars.filter(isEmojiScalar).map { String($0) }
}
private static func removeAllEmojis(from text: String) -> String {
String(text.unicodeScalars.filter { !isEmojiScalar($0) })
.replacingOccurrences(of: " ", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
private static func isEmojiScalar(_ scalar: Unicode.Scalar) -> Bool {
scalar.properties.isEmoji && (scalar.value > 0x238C || scalar.properties.isEmojiPresentation)
}
private static func isCJKScalar(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
return true
default:
return false
}
}
}
+6 -5
View File
@@ -5,7 +5,8 @@
"engine.asr.appleSpeech" = "Apple SpeechAnalyzer";
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
"flow.warning.cloudPolishMissingKey" = "Cloud polish/translation needs a DeepSeek API key. Inserted raw ASR text — set PreconfiguredKeys.deepseek in the project (local engine) or API key in Settings (cloud engine).";
"flow.warning.cloudPolishMissingKey" = "Cloud polish needs an API key in Settings. Inserted raw ASR text.";
"flow.warning.localPolishUnavailable" = "Built-in polish is unavailable. Inserted raw ASR text.";
/* LLM providers */
"provider.openai" = "OpenAI";
@@ -54,10 +55,10 @@
"polish.intensity.light" = "Light";
"polish.intensity.medium" = "Medium";
"polish.intensity.heavy" = "Heavy";
"polish.intensity.off.desc" = "Insert the raw ASR transcript with no LLM call.";
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications.";
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Default.";
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs. Best for meeting notes and reports.";
"polish.intensity.off.desc" = "No polish; inserts raw ASR unless your personal dictionary has entries, then runs ASR correction only.";
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications. Punctuation and structure still apply.";
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Punctuation and structure always apply. Default.";
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs when needed. Best for meeting notes and reports.";
/* v0.3.0: Detected app context labels */
"appContext.code" = "Code";
@@ -5,7 +5,8 @@
"engine.asr.appleSpeech" = "Apple 语音识别";
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
"flow.warning.cloudPolishMissingKey" = "云端润色/翻译需要 DeepSeek API Key,本次已插入原始识别结果。本地引擎请在 PreconfiguredKeys.swift 配置;云端引擎请在设置中填写 API Key。";
"flow.warning.cloudPolishMissingKey" = "云端润色需要先在设置中填写 API Key,本次已插入原始识别结果。";
"flow.warning.localPolishUnavailable" = "内置润色暂不可用,本次已插入原始识别结果。";
/* LLM providers */
"provider.openai" = "OpenAI";
@@ -54,9 +55,9 @@
"polish.intensity.light" = "轻度";
"polish.intensity.medium" = "中度";
"polish.intensity.heavy" = "深度";
"polish.intensity.off.desc" = "不调用 LLM,直接插入识别原文。";
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误。";
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句。推荐默认。";
"polish.intensity.off.desc" = "不润色;词库为空时直接插入识别原文,有词条时仅做 ASR 纠错。";
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误;标点和结构化仍会生效。";
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句;始终补标点与结构化。推荐默认。";
"polish.intensity.heavy.desc" = "可重组段落、拆长句、自动编号。适合会议纪要与报告。";
/* v0.3.0: 输入场景标签 */
@@ -0,0 +1,139 @@
// CursorNavigationTests.swift
// OSGKeyboardTests
import XCTest
@testable import OSGKeyboardShared
final class CursorNavigationTests: XCTestCase {
/// Uses the built-in 1/2-unit width table. `lineWidth` is therefore in
/// "units" ( Latin characters) for these tests.
private func config(lineWidth: CGFloat) -> CursorNavigation.VisualLineLayoutConfig {
CursorNavigation.VisualLineLayoutConfig(lineWidth: lineWidth)
}
func testColumnOnFirstLine() {
XCTAssertEqual(CursorNavigation.column(before: "hello"), 5)
XCTAssertEqual(CursorNavigation.column(before: nil), 0)
}
func testColumnAfterNewline() {
XCTAssertEqual(CursorNavigation.column(before: "hello\nwor"), 3)
}
func testDefaultDisplayWidthLatinAndCJK() {
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("a"), 1)
XCTAssertEqual(CursorNavigation.defaultDisplayWidth(""), 2)
XCTAssertEqual(CursorNavigation.defaultDisplayWidth("\n"), 0)
}
func testVisualLineDownAcrossSoftWrap() {
// 20 Latin chars, wrap at 16 line0 [0,16), line1 [16,20).
let text = String(repeating: "a", count: 20)
let before = String(text.prefix(8))
let after = String(text.suffix(12))
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: nil,
config: config(lineWidth: 16)
)
XCTAssertNotNil(result)
// Sticky column 8; line1 only has 4 units clamp to its end.
XCTAssertEqual(result?.offset, 12)
}
func testVisualLineDownToShorterWrappedLineClampsToEnd() {
// Caret at col 15 (clearly on line0); line1 has only 4 chars.
let text = String(repeating: "a", count: 20)
let before = String(text.prefix(15))
let after = String(text.suffix(5))
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: 15,
config: config(lineWidth: 16)
)
XCTAssertNotNil(result)
XCTAssertEqual(result?.offset, 5)
XCTAssertEqual(result?.stickyColumn, 15)
}
func testVisualLineUpAcrossSoftWrap() {
let text = String(repeating: "a", count: 20)
let before = String(text.prefix(18))
let after = String(text.suffix(2))
let result = CursorNavigation.visualLineUpOffset(
before: before,
after: after,
preferredDisplayColumn: 8,
config: config(lineWidth: 16)
)
XCTAssertNotNil(result)
XCTAssertEqual(result?.offset, -10)
}
func testVisualLineDownAcrossHardNewline() {
let before = "hello\nwor"
let after = "ld\nfoo"
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
XCTAssertNotNil(result)
// "hello\nworld\nfoo" caret before "ld"; column 3 lands after "foo".
XCTAssertEqual(result?.offset, 6)
}
func testVisualLineDownPreservesStickyColumnOnLongerNextLine() {
let before = "hello\nwor"
let after = "ld\nfoobarbaz"
let result = CursorNavigation.visualLineDownOffset(
before: before,
after: after,
preferredDisplayColumn: 5,
config: config(lineWidth: 100)
)
XCTAssertNotNil(result)
// "ld\n" (3) + column 5 on "foobarbaz" = 8 total from cursor.
XCTAssertEqual(result?.offset, 8)
XCTAssertEqual(result?.stickyColumn, 5)
}
func testVisualLineUpOnFirstLineReturnsNil() {
XCTAssertNil(
CursorNavigation.visualLineUpOffset(
before: "hello",
after: " world",
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
)
}
func testVisualLineDownWithNoFollowingTextReturnsNil() {
XCTAssertNil(
CursorNavigation.visualLineDownOffset(
before: "hello",
after: nil,
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
)
XCTAssertNil(
CursorNavigation.visualLineDownOffset(
before: "hello",
after: "",
preferredDisplayColumn: nil,
config: config(lineWidth: 100)
)
)
}
}
+269 -98
View File
@@ -2,7 +2,7 @@
// OSGKeyboard · Tests
//
// v0.3.0: locks the behavior of the rewritten PolishingService and
// its two supporting services (AppContextDetector, DictionaryLearner).
// its supporting service (AppContextDetector).
// The tests are deliberately hermetic no LLMClient, no ASR, no
// App Group so they run in <100 ms total.
@@ -18,11 +18,6 @@ final class IntelligentPolishTests: XCTestCase {
override func setUp() {
super.setUp()
// Each test gets a fresh, throwaway UserDefaults suite so
// engine mode / API key / dictionary / context state does
// not leak between tests. The AppGroupStore falls back to
// `.standard` when no App Group entitlement is present, so
// we point it at a private suite to keep this test hermetic.
suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
@@ -34,31 +29,95 @@ final class IntelligentPolishTests: XCTestCase {
super.tearDown()
}
// MARK: - PolishingService prompt construction
// MARK: - Polish intensity migration
func testPolishServiceOffIntensitySkipsLLM() async throws {
// When intensity is `.off`, the service must return the
// raw input unchanged *and* not touch the LLM. We assert
// both by passing a deliberately broken LLM client and
// expecting the call to return cleanly.
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient() // would throw if invoked
)
let result = try await service.polish("hello world", context: PolishContext(intensity: .off))
XCTAssertEqual(result, "hello world")
func testPolishIntensityMigratesLegacyOffToMedium() {
defaults.set(PolishIntensity.legacyOffRawValue, forKey: "config.polishIntensity")
XCTAssertEqual(store.polishIntensity, .medium)
XCTAssertEqual(defaults.string(forKey: "config.polishIntensity"), PolishIntensity.medium.rawValue)
}
func testPolishServiceLocalEngineWithoutCloudPolishReturnsRaw() async throws {
store.setEngineMode("local")
// localModeCloudPolishEnabled defaults to false.
func testPolishIntensityResolveLegacyOff() {
XCTAssertEqual(PolishIntensity.resolve(storedRawValue: "off"), .medium)
}
// MARK: - PolishingService prompt construction
func testPolishServiceUltraShortTextSkipsLLM() async throws {
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
client: ThrowingLLMClient()
)
let result = try await service.polish("hello world", context: PolishContext(intensity: .medium))
XCTAssertEqual(result, "hello world")
let result = try await service.polish("", context: PolishContext(intensity: .heavy))
XCTAssertEqual(result, "")
}
func testPolishServiceShortStructuredTextStillInvokesLLM() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"第一点测试第二点上线",
context: PolishContext(intensity: .medium)
)
XCTAssertFalse(captured.lastPrompt.isEmpty)
}
func testPersonalDictionaryUpsertManual() {
var dict = PersonalDictionary.empty
let entry = dict.upsertManual(term: "Kubernetes")
XCTAssertEqual(entry?.term, "Kubernetes")
XCTAssertEqual(entry?.source, .manual)
XCTAssertEqual(dict.entries.count, 1)
let updated = dict.upsertManual(term: "kubernetes", existingID: entry?.id)
XCTAssertEqual(updated?.term, "kubernetes")
XCTAssertEqual(dict.entries.count, 1)
}
func testDictionaryAliasGeneratorParsesJSONArray() {
let aliases = DictionaryAliasGenerator.parseAliases(
from: #"["k8s",""]"#,
excludingTerm: "Kubernetes"
)
XCTAssertEqual(aliases, ["k8s", "库伯内特斯"])
}
func testDictionaryAliasGeneratorExcludesCanonicalTerm() {
let aliases = DictionaryAliasGenerator.parseAliases(
from: #"["Kubernetes","k8s"]"#,
excludingTerm: "Kubernetes"
)
XCTAssertEqual(aliases, ["k8s"])
}
func testEntryInferCategoryForChinese() {
XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "张三"), .properNoun)
XCTAssertEqual(PersonalDictionary.Entry.inferCategory(for: "LLM"), .acronym)
}
func testPersonalDictionaryMigratesLegacyHistorySource() {
let legacy = PersonalDictionary(entries: [
PersonalDictionary.Entry(term: "Kubernetes", category: .productName, source: .history),
])
let data = try! JSONEncoder().encode(legacy)
defaults.set(data, forKey: "config.personalDictionary.v1")
let loaded = store.personalDictionary
XCTAssertEqual(loaded.entries.first?.source, .manual)
XCTAssertEqual(loaded.entries.first?.term, "Kubernetes")
}
func testPolishServiceLocalEngineInvokesLLM() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天我们部署 k8s 集群",
context: PolishContext(appContext: .code, intensity: .medium)
)
XCTAssertFalse(captured.lastPrompt.isEmpty)
}
func testPolishServiceMissingAPIKeyThrows() async {
@@ -78,9 +137,6 @@ final class IntelligentPolishTests: XCTestCase {
}
func testPolishServiceShortTextSkipsLLM() async throws {
// Per the prompt's hard rule #4, 8 CJK chars / 15
// English words must be returned verbatim. We exercise
// the upper bound here.
store.setEngineMode("cloud")
let service = PolishingService(
store: store,
@@ -107,24 +163,174 @@ final class IntelligentPolishTests: XCTestCase {
"Prompt must include dictionary term. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("Code context"),
"Prompt must include app-context guideline. Got: \(captured.lastPrompt)")
XCTAssertTrue(captured.lastPrompt.contains("medium") || captured.lastPrompt.contains("中度"),
"Prompt must mention the intensity. Got: \(captured.lastPrompt)")
XCTAssertTrue(
captured.lastPrompt.contains("全局输出契约") || captured.lastPrompt.contains("Global output contract"),
"Prompt must include global output contract. Got: \(captured.lastPrompt.prefix(200))"
)
XCTAssertTrue(
captured.lastPrompt.localizedCaseInsensitiveContains("emoji"),
"Prompt must include strict emoji control guidance. Got: \(captured.lastPrompt)"
)
XCTAssertFalse(
captured.lastPrompt.localizedCaseInsensitiveContains("emoji-friendly"),
"Chat context must not encourage emojis. Got: \(captured.lastPrompt)"
)
}
func testPolishServicePromptIncludesStructureRulesAtLightIntensity() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish(
"今天有三个任务第一点修复登录第二点优化键盘",
context: PolishContext(intensity: .light)
)
XCTAssertTrue(
captured.lastPrompt.contains("第一点") || captured.lastPrompt.contains("numbered"),
"Light intensity must still include structure rules. Got: \(captured.lastPrompt.prefix(300))"
)
}
func testPolishServiceScalesTimeoutWithTextLength() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured, timeout: 15)
let longText = String(repeating: "这是一段比较长的语音识别测试文本,", count: 20)
_ = try await service.polish(longText, context: PolishContext(intensity: .medium))
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertGreaterThan(
passedTimeout, 15,
"Long transcripts must scale the per-request HTTP timeout above the baseline"
)
}
func testPolishServiceCapsTimeoutAt120() async throws {
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured, timeout: 15)
let veryLong = String(repeating: "测试", count: 2000)
_ = try await service.polish(veryLong, context: PolishContext(intensity: .medium))
let passedTimeout = try XCTUnwrap(captured.lastTimeout)
XCTAssertLessThanOrEqual(passedTimeout, 120)
}
func testPolishServiceUsesChineseForChineseProviders() async throws {
defaults.set("deepseek", forKey: "config.providerId")
store.setEngineMode("cloud")
store.setEngineMode("local")
let captured = CapturingLLMClient()
let service = PolishingService(store: store, client: captured)
_ = try await service.polish("hello", context: PolishContext(intensity: .medium))
// The polisher routes Chinese providers through the Chinese
// prompt, which is identifiable by its "" header.
XCTAssertTrue(
captured.lastPrompt.contains("三件事"),
"DeepSeek should get the Chinese prompt. Got prefix: \(captured.lastPrompt.prefix(80))"
captured.lastPrompt.contains("全局输出契约"),
"Local engine should get the Chinese prompt via DeepSeek. Got prefix: \(captured.lastPrompt.prefix(80))"
)
}
func testPolishServiceStripsAddedEmojiFromLLMOutput() async throws {
store.setEngineMode("local")
let emojiClient = FixedResponseLLMClient(response: "今天的工作已经全部完成了👍")
let service = PolishingService(store: store, client: emojiClient)
let result = try await service.polish(
"今天的工作已经全部完成了",
context: PolishContext(intensity: .medium)
)
XCTAssertFalse(result.contains("👍"))
XCTAssertTrue(result.contains("完成"))
}
func testPolishServiceFallsBackWhenOutputEmpty() async throws {
store.setEngineMode("local")
let emptyClient = FixedResponseLLMClient(response: " ")
let service = PolishingService(store: store, client: emptyClient)
let result = try await service.polish(
"今天的部署已经全部完成",
context: PolishContext(intensity: .medium)
)
XCTAssertEqual(result, "今天的部署已经全部完成")
}
// MARK: - TranscriptPostProcessor
func testShouldSkipLLMForUltraShortWithoutStructure() {
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: ""))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "OK"))
XCTAssertTrue(TranscriptPostProcessor.shouldSkipLLM(for: "明天见"))
}
func testShouldNotSkipLLMWhenStructurePresent() {
XCTAssertFalse(TranscriptPostProcessor.shouldSkipLLM(for: "第一点做完第二点再做"))
}
func testStripAddedEmojisRemovesNewEmoji() {
let result = TranscriptPostProcessor.stripAddedEmojis(
original: "好的",
output: "好的👍"
)
XCTAssertEqual(result, "好的")
}
func testNormalizeNumberedLists() {
let input = "第一点 修复\n第二点 上线"
let output = TranscriptPostProcessor.normalizeNumberedLists(input)
XCTAssertTrue(output.contains("1. 修复"))
XCTAssertTrue(output.contains("2. 上线"))
}
func testQualityGateNeverRevertsToRawOnNumberChange() {
// Listifying / fixing ASR number-mishearings legitimately
// changes the number set this must NOT revert to the raw text.
let decision = TranscriptPostProcessor.qualityGate(
original: "第一点测试第2:00上线",
candidate: "1. 测试\n2. 上线"
)
if case .accept(let text) = decision {
XCTAssertTrue(text.contains("1. 测试"))
XCTAssertTrue(text.contains("2. 上线"))
} else {
XCTFail("Expected accept — number changes must not trigger raw fallback")
}
}
func testQualityGateStillFallsBackOnEmptyOutput() {
let decision = TranscriptPostProcessor.qualityGate(
original: "部署完成",
candidate: " "
)
if case .fallback(let text) = decision {
XCTAssertEqual(text, "部署完成")
} else {
XCTFail("Expected fallback on empty output")
}
}
func testRepairMidSentenceLineBreakJoinsBrokenSentence() {
let input = "你是不是真的解决了这个格式化和标点符号包括\n这些问题"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, "你是不是真的解决了这个格式化和标点符号包括这些问题")
}
func testRepairMidSentenceLineBreakKeepsSentenceBoundary() {
let input = "今天完成了部署。\n明天开始测试。"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, input)
}
func testRepairMidSentenceLineBreakKeepsListItems() {
let input = "1. 修复登录\n2. 优化键盘"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, input)
}
func testRepairMidSentenceLineBreakJoinsEnglishWithSpace() {
let input = "this is a broken\nsentence"
let output = TranscriptPostProcessor.repairMidSentenceLineBreaks(input)
XCTAssertEqual(output, "this is a broken sentence")
}
func testHasStructureSignalDetectsChineseEnumeration() {
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "首先测试其次上线"))
XCTAssertTrue(TranscriptPostProcessor.hasStructureSignal(in: "第一点修复"))
}
// MARK: - AppContextDetector
func testAppContextDetectorRecognizesCodeByIndentation() {
@@ -165,29 +371,32 @@ final class IntelligentPolishTests: XCTestCase {
func testAppContextDetectorFallbackChain() {
let detector = AppContextDetector()
// No preceding text and no cache environmental fallback.
let env = detector.detect(
precedingText: nil,
storedCache: nil,
now: Date(timeIntervalSince1970: 1_700_000_000) // a workday moment
now: Date(timeIntervalSince1970: 1_700_000_000)
)
XCTAssertNotEqual(env, .unknown)
}
func testAppContextDetectorCacheWinsOverFallback() {
let detector = AppContextDetector()
// 5-minute-old cache with `.code` must be returned even
// when there is no preceding text.
let cache = (context: AppContext.code, observedAt: Date().addingTimeInterval(-300))
let result = detector.detect(precedingText: "", storedCache: cache)
XCTAssertEqual(result, .code)
}
func testChatAppContextGuidelineDoesNotEncourageEmoji() {
let guideline = AppContext.chat.polishGuideline
XCTAssertFalse(guideline.localizedCaseInsensitiveContains("emoji-friendly"))
XCTAssertTrue(guideline.localizedCaseInsensitiveContains("Do not add emojis"))
}
// MARK: - PersonalDictionary.promptFragment
func testDictionaryPromptFragmentIsEmptyForEmptyDictionary() {
func testDictionaryPromptFragmentIncludesBuiltInOSGKeyboard() {
let prompt = PersonalDictionary.empty.promptFragment()
XCTAssertEqual(prompt, "")
XCTAssertTrue(prompt.contains("OSGKeyboard"))
}
func testDictionaryPromptFragmentGroupsByCategory() {
@@ -197,86 +406,48 @@ final class IntelligentPolishTests: XCTestCase {
PersonalDictionary.Entry(term: "Rocky", category: .properNoun, source: .manual),
])
let prompt = dict.promptFragment()
XCTAssertTrue(prompt.contains("OSGKeyboard"))
XCTAssertTrue(prompt.contains("Kubernetes"))
XCTAssertTrue(prompt.contains("iOS"))
XCTAssertTrue(prompt.contains("Rocky"))
}
// MARK: - DictionaryLearner
func testLearnerPromotesRepeatedCapitalizedToken() {
let history: [SpeechHistoryEntry] = [
.init(text: "Deploy Kubernetes today", engineMode: "cloud"),
.init(text: "Restart Kubernetes pod", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
XCTAssertTrue(added.contains { $0.term == "Kubernetes" },
"Kubernetes should be promoted. Got: \(added.map(\.term))")
}
func testLearnerIgnoresStopwords() {
let history: [SpeechHistoryEntry] = [
.init(text: "this is the test", engineMode: "cloud"),
.init(text: "this is the second test", engineMode: "cloud"),
.init(text: "this is the third test", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
let terms = Set(added.map(\.term))
XCTAssertFalse(terms.contains("this"))
XCTAssertFalse(terms.contains("the"))
XCTAssertFalse(terms.contains("is"))
}
func testLearnerRespectsMinimumOccurrence() {
let history: [SpeechHistoryEntry] = [
.init(text: "First time mentioning Whisper", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let added = learner.learn(from: history)
XCTAssertFalse(added.contains { $0.term == "Whisper" })
}
func testLearnerIdempotent() {
let history: [SpeechHistoryEntry] = [
.init(text: "OpenAI rocks", engineMode: "cloud"),
.init(text: "OpenAI again", engineMode: "cloud"),
]
let learner = DictionaryLearner(minOccurrences: 2)
let first = learner.learn(from: history)
let second = learner.learn(from: history)
XCTAssertTrue(first.contains { $0.term == "OpenAI" })
// Second call must not double-add; the existing entry's
// usage count is bumped instead.
let openaiEntries = second.filter { $0.term == "OpenAI" }
XCTAssertEqual(openaiEntries.count, 1)
}
}
// MARK: - Test doubles
/// Records every call so the test can inspect the prompt the
/// polisher would have sent. We do not assert on `response`; the
/// LLMClient contract is exercised by `LLMClientTests`.
private final class CapturingLLMClient: LLMClient, @unchecked Sendable {
private(set) var lastPrompt: String = ""
private(set) var lastTimeout: TimeInterval?
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String {
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
lastPrompt = systemPrompt
lastTimeout = timeout
return text
}
}
private final class EchoLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String { text }
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String { text }
}
private final class ThrowingLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
func polish(_ text: String, systemPrompt: String) async throws -> String {
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
throw LLMError.cancelled
}
}
private final class FixedResponseLLMClient: LLMClient, @unchecked Sendable {
let requestTimeout: TimeInterval = 15
private let response: String
init(response: String) {
self.response = response
}
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
response
}
}
@@ -101,8 +101,12 @@ final class KeyboardOnboardingOverlayTests: XCTestCase {
func testPolishIntensityRoundTrip() {
store.setPolishIntensity(.heavy)
XCTAssertEqual(store.polishIntensity, .heavy)
store.setPolishIntensity(.off)
XCTAssertEqual(store.polishIntensity, .off,
"off should round-trip through UserDefaults (NOT skip the write)")
store.setPolishIntensity(.light)
XCTAssertEqual(store.polishIntensity, .light)
}
func testPolishIntensityLegacyOffMigratesToMedium() {
defaults.set(PolishIntensity.legacyOffRawValue, forKey: "config.polishIntensity")
XCTAssertEqual(store.polishIntensity, .medium)
}
}
+11
View File
@@ -12,10 +12,14 @@ final class KeychainTests: XCTestCase {
override func setUpWithError() throws {
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
}
override func tearDownWithError() throws {
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
}
// MARK: - Round-trip
@@ -49,6 +53,13 @@ final class KeychainTests: XCTestCase {
XCTAssertNil(Keychain.apiKey(), "Empty write must delete, not store empty string")
}
func testProviderScopedKeysDoNotMix() throws {
try Keychain.setAPIKey("sk-openai", for: "openai")
try Keychain.setAPIKey("sk-qwen", for: "qwen")
XCTAssertEqual(Keychain.apiKey(for: "openai"), "sk-openai")
XCTAssertEqual(Keychain.apiKey(for: "qwen"), "sk-qwen")
}
/// Deleting a non-existent entry must be a no-op (idempotent), not an
/// error callers like `ProviderConfig.reset()` invoke it
/// unconditionally.
+10 -48
View File
@@ -15,6 +15,8 @@ final class LLMClientTests: XCTestCase {
// leak into the next one unless we wipe it here. We intentionally
// swallow errors `errSecItemNotFound` is fine.
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
StubURLProtocolStorage.config = nil
StubURLProtocolStorage.delaySeconds = 0
StubURLProtocolStorage.lastRequest = nil
@@ -22,6 +24,8 @@ final class LLMClientTests: XCTestCase {
override func tearDownWithError() throws {
try? Keychain.deleteAPIKey()
try? Keychain.deleteLegacyAPIKey()
try? Keychain.deleteAPIKey(for: "qwen")
StubURLProtocolStorage.config = nil
StubURLProtocolStorage.delaySeconds = 0
StubURLProtocolStorage.lastRequest = nil
@@ -394,54 +398,13 @@ final class LLMClientTests: XCTestCase {
XCTAssertFalse(cloudStore.isTranslationEffective)
defaults.set("local", forKey: "config.engineMode")
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
let localPolishOn = AppGroupStore(defaults: defaults)
XCTAssertTrue(localPolishOn.isTranslationChipVisible)
XCTAssertFalse(localPolishOn.isTranslationEffective)
defaults.set(false, forKey: "config.localModeCloudPolishEnabled")
let localPolishOff = AppGroupStore(defaults: defaults)
XCTAssertFalse(localPolishOff.isTranslationChipVisible)
defaults.set(TranslationLanguageCatalog.offLocaleId, forKey: "config.translationTargetLocaleId")
let localStore = AppGroupStore(defaults: defaults)
XCTAssertTrue(localStore.isTranslationChipVisible)
XCTAssertFalse(localStore.isTranslationEffective)
}
/// Local engine with translation enabled must invoke the LLM even
/// when the cloud-polish toggle is off.
func testPolisherSkipsLLMWhenLocalCloudPolishOffEvenWithTranslation() async throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set("local", forKey: "config.engineMode")
defaults.set("en", forKey: "config.translationTargetLocaleId")
defaults.set(false, forKey: "config.localModeCloudPolishEnabled")
let counter = CallCounter()
let countingClient = CountingLLMClient(counter: counter) { _, _ in
XCTFail("cloud LLMClient must not run when local cloud polish is off")
return ""
}
let store = AppGroupStore(defaults: defaults)
XCTAssertFalse(store.shouldRunCloudLLMStep)
let polisher = PolishingService(
store: store,
client: countingClient,
timeout: 1
)
let result = try await polisher.polish(
" 你好 ",
mode: .translate(targetLocaleId: "en"),
providerIdOverride: "deepseek"
)
XCTAssertEqual(result, "你好")
let calls = await counter.value()
XCTAssertEqual(calls, 0)
}
/// Local engine with cloud polish + translation enabled invokes LLM.
/// Local engine always runs the LLM step when translation is armed.
func testPolisherTranslatesWhenLocalEngineTranslationEnabled() async throws {
let suiteName = "group.com.osgkeyboard.shared.tests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
@@ -450,7 +413,6 @@ final class LLMClientTests: XCTestCase {
defaults.set("local", forKey: "config.engineMode")
defaults.set("en", forKey: "config.translationTargetLocaleId")
defaults.set(true, forKey: "config.localModeCloudPolishEnabled")
let counter = CallCounter()
let countingClient = CountingLLMClient(counter: counter) { raw, prompt in
@@ -504,7 +466,7 @@ private struct CountingLLMClient: LLMClient {
var requestTimeout: TimeInterval { 15 }
func polish(_ text: String, systemPrompt: String) async throws -> String {
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
await counter.bump()
return try await body(text, systemPrompt)
}
+14
View File
@@ -21,6 +21,20 @@ if [[ ! -f "$SIGNING_LOCAL" ]]; then
echo "Edit DEVELOPMENT_TEAM there if you use a personal Apple Developer account."
fi
# Local-engine DeepSeek key (gitignored). XcodeGen compiles this file;
# the example ships a placeholder so fresh clones build after copy.
PRECONFIG_LOCAL="$ROOT/OSGKeyboardShared/Services/PreconfiguredKeys.local.swift"
PRECONFIG_EXAMPLE="$ROOT/OSGKeyboardShared/Services/PreconfiguredKeys.local.swift.example"
if [[ ! -f "$PRECONFIG_LOCAL" ]]; then
if [[ ! -f "$PRECONFIG_EXAMPLE" ]]; then
echo "error: missing $PRECONFIG_EXAMPLE" >&2
exit 1
fi
cp "$PRECONFIG_EXAMPLE" "$PRECONFIG_LOCAL"
echo "Created $PRECONFIG_LOCAL from PreconfiguredKeys.local.swift.example"
echo "Edit deepseek in that file before using the local engine's built-in polish."
fi
xcodegen generate
if python3 - "$PBXPROJ" <<'PY'
+3 -2
View File
@@ -36,8 +36,8 @@ settings:
GENERATE_INFOPLIST_FILE: NO
ENABLE_MODULE_VERIFIER: YES
CLANG_CXX_LANGUAGE_STANDARD: c++17
MARKETING_VERSION: "0.3.2"
CURRENT_PROJECT_VERSION: "7"
MARKETING_VERSION: "0.3.6"
CURRENT_PROJECT_VERSION: "10"
# 签名配置来自 Signing.local.xcconfiggitignored,不会被覆盖)
# 项目级签名 xcconfig,适用于所有 target
@@ -215,6 +215,7 @@ targets:
excludes:
- "en.lproj"
- "zh-Hans.lproj"
- "**/*.swift.example"
- path: OSGKeyboardShared/en.lproj/Shared.strings
buildPhase: resources
- path: OSGKeyboardShared/zh-Hans.lproj/Shared.strings