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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,5 +31,6 @@ struct HelpFeedbackView: View {
|
||||
.background(palette.background.ignoresSafeArea())
|
||||
.navigationTitle("settings.link.support")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.hidesTabBarWhenPushed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.";
|
||||
|
||||
@@ -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" = "这会移除所有受保护的词汇,且无法撤销。";
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" = "通用 — 中性口吻";
|
||||
|
||||
@@ -16,7 +16,7 @@ import SwiftUI
|
||||
/// `DesignSystem/ThemedRoot.swift`. Token names mirror the previous
|
||||
/// `Palette` static API so existing call sites (`Palette.background` etc.)
|
||||
/// still compile and resolve through the legacy static accessors below.
|
||||
public struct ThemePalette: Sendable {
|
||||
public struct ThemePalette: Sendable, Equatable {
|
||||
public let background: Color
|
||||
public let surface: Color
|
||||
public let surfaceElevated: Color
|
||||
|
||||
@@ -46,7 +46,7 @@ public enum AppContext: String, Codable, Sendable, CaseIterable {
|
||||
case .email:
|
||||
return "Email context: you may add a polite greeting or sign-off if the user clearly forgot one. Reasonable paragraph breaks. Keep tone professional but not stiff."
|
||||
case .chat:
|
||||
return "Chat context: keep it short, conversational, and emoji-friendly. Drop formalities. Preserve the speaker's casual voice."
|
||||
return "Chat context: keep it short, conversational, and natural. Drop formalities. Preserve the speaker's casual voice. Do not add emojis."
|
||||
case .document:
|
||||
return "Document context: add structure — split into paragraphs, use lists when the user enumerates. Keep tone written-formal. Do not invent headings the user did not say."
|
||||
case .unknown:
|
||||
|
||||
@@ -52,13 +52,11 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
id: "deepseek",
|
||||
name: "DeepSeek",
|
||||
defaultBaseURL: "https://api.deepseek.com/v1",
|
||||
// v0.2.0: bumped default to `deepseek-v4-flash` for the
|
||||
// local-mode cloud-polish toggle. `deepseek-chat` is
|
||||
// retained as a valid user-overridable model name; only
|
||||
// the default is updated.
|
||||
defaultModel: "deepseek-v4-flash",
|
||||
apiKeyURL: URL(string: "https://platform.deepseek.com/api_keys"),
|
||||
blurb: "deepseek-v4-flash · 默认 · 快速且中文友好"
|
||||
blurb: "deepseek-v4-flash · 本地引擎内置 · Local engine built-in",
|
||||
// Local engine only — never shown in cloud-engine pickers.
|
||||
isUserSelectable: false
|
||||
),
|
||||
.init(
|
||||
id: "qwen",
|
||||
@@ -96,4 +94,10 @@ public struct LLMProvider: Identifiable, Codable, Hashable, Sendable {
|
||||
public static func provider(id: String) -> LLMProvider {
|
||||
presets.first(where: { $0.id == id }) ?? .presets[0]
|
||||
}
|
||||
|
||||
/// Presets the user may pick in Settings / onboarding. DeepSeek is
|
||||
/// excluded — it is wired exclusively to the local engine.
|
||||
public static var userSelectablePresets: [LLMProvider] {
|
||||
presets.filter(\.isUserSelectable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
//
|
||||
// Sources (mutually exclusive per entry):
|
||||
// - `.manual` user typed it in by hand
|
||||
// - `.history` auto-extracted from the user's transcription
|
||||
// history by `DictionaryLearner`
|
||||
// - `.history` legacy auto-learned entries (migrated to `.manual`)
|
||||
// - `.contacts` imported from the iOS Contacts framework
|
||||
// - `.recentEdit` extracted from edits the user made to a
|
||||
// polished transcript before sending
|
||||
@@ -104,13 +103,124 @@ public struct PersonalDictionary: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary.Entry {
|
||||
/// Lightweight category inference for manual adds and the history
|
||||
/// learner. Users can re-classify later from Settings.
|
||||
public static func inferCategory(for term: String) -> Category {
|
||||
let hasUpper = term.contains(where: { $0.isUppercase })
|
||||
let hasDigit = term.contains(where: { $0.isNumber })
|
||||
let hasLatin = term.unicodeScalars.contains { scalar in
|
||||
CharacterSet.letters.contains(scalar) && scalar.isASCII
|
||||
}
|
||||
if hasUpper, !term.contains(where: { $0.isLowercase }) {
|
||||
return .acronym
|
||||
}
|
||||
if hasDigit {
|
||||
return .productName
|
||||
}
|
||||
if !hasLatin {
|
||||
return .properNoun
|
||||
}
|
||||
return .productName
|
||||
}
|
||||
}
|
||||
|
||||
extension PersonalDictionary {
|
||||
public static let empty = PersonalDictionary()
|
||||
|
||||
/// Built-in terms always included in LLM prompts. Never persisted
|
||||
/// and never shown in the Settings personal-dictionary UI.
|
||||
public static let systemEntries: [Entry] = [
|
||||
Entry(
|
||||
id: UUID(uuidString: "A0000000-0000-4000-8000-000000000001")!,
|
||||
term: "OSGKeyboard",
|
||||
aliases: [],
|
||||
category: .productName,
|
||||
source: .manual,
|
||||
createdAt: Date(timeIntervalSince1970: 0),
|
||||
usageCount: 0
|
||||
),
|
||||
]
|
||||
|
||||
/// User entries plus built-in system terms (deduped by term).
|
||||
public var effectiveEntries: [Entry] {
|
||||
var merged = Self.systemEntries
|
||||
let systemTerms = Set(Self.systemEntries.map { $0.term.lowercased() })
|
||||
for entry in entries where !systemTerms.contains(entry.term.lowercased()) {
|
||||
merged.append(entry)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/// Case-insensitive lookup by canonical term.
|
||||
public func entry(matchingTerm term: String) -> Entry? {
|
||||
let key = term.lowercased()
|
||||
return entries.first { $0.term.lowercased() == key }
|
||||
}
|
||||
|
||||
/// Insert or update a manual entry. Returns the saved entry.
|
||||
@discardableResult
|
||||
public mutating func upsertManual(
|
||||
term: String,
|
||||
existingID: UUID? = nil,
|
||||
regenerateAliases: Bool = false
|
||||
) -> Entry? {
|
||||
let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
let category = Entry.inferCategory(for: trimmed)
|
||||
|
||||
if let existingID,
|
||||
let idx = entries.firstIndex(where: { $0.id == existingID }) {
|
||||
var entry = entries[idx]
|
||||
let termChanged = entry.term.caseInsensitiveCompare(trimmed) != .orderedSame
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
if termChanged || regenerateAliases {
|
||||
entry.aliases = []
|
||||
}
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
if let idx = entries.firstIndex(where: {
|
||||
$0.term.caseInsensitiveCompare(trimmed) == .orderedSame
|
||||
}) {
|
||||
var entry = entries[idx]
|
||||
entry.term = trimmed
|
||||
entry.category = category
|
||||
entry.source = .manual
|
||||
entries[idx] = entry
|
||||
return entry
|
||||
}
|
||||
|
||||
let entry = Entry(
|
||||
term: trimmed,
|
||||
aliases: [],
|
||||
category: category,
|
||||
source: .manual
|
||||
)
|
||||
entries.append(entry)
|
||||
return entry
|
||||
}
|
||||
|
||||
public mutating func updateAliases(for entryID: UUID, aliases: [String]) {
|
||||
guard let idx = entries.firstIndex(where: { $0.id == entryID }) else { return }
|
||||
let cleaned = aliases
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
let termLower = entries[idx].term.lowercased()
|
||||
entries[idx].aliases = Array(
|
||||
Set(cleaned.filter { $0.lowercased() != termLower })
|
||||
).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Renders the entire dictionary as a prompt fragment. Entries
|
||||
/// are grouped by category so the LLM can scan quickly. Empty
|
||||
/// dictionary returns "" so the caller can blindly concatenate.
|
||||
public func promptFragment() -> String {
|
||||
let entries = effectiveEntries
|
||||
guard !entries.isEmpty else { return "" }
|
||||
let grouped = Dictionary(grouping: entries, by: { $0.category })
|
||||
var lines: [String] = []
|
||||
|
||||
@@ -9,14 +9,9 @@
|
||||
import Foundation
|
||||
|
||||
public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// Engine is in pure ASR mode (local + cloud-polish-off). The LLM
|
||||
/// is never called; the raw transcript is inserted as-is. This
|
||||
/// value is mostly a UI default — the actual behaviour is
|
||||
/// determined by `engineMode` + `localModeCloudPolishEnabled`.
|
||||
case off
|
||||
|
||||
/// Drop only isolated filler words (嗯 / 呃 / 那个 / 就是 / 然后)
|
||||
/// and obvious duplicated fragments. Everything else stays.
|
||||
/// and obvious duplicated fragments. Punctuation and structure
|
||||
/// formatting still apply at every intensity level.
|
||||
case light
|
||||
|
||||
/// Correction + light polish: drop fillers, fix homophone errors,
|
||||
@@ -34,7 +29,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// and the keyboard extension.
|
||||
public var labelKey: String {
|
||||
switch self {
|
||||
case .off: return "polish.intensity.off"
|
||||
case .light: return "polish.intensity.light"
|
||||
case .medium: return "polish.intensity.medium"
|
||||
case .heavy: return "polish.intensity.heavy"
|
||||
@@ -45,7 +39,6 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// story as `labelKey`.
|
||||
public var descriptionKey: String {
|
||||
switch self {
|
||||
case .off: return "polish.intensity.off.desc"
|
||||
case .light: return "polish.intensity.light.desc"
|
||||
case .medium: return "polish.intensity.medium.desc"
|
||||
case .heavy: return "polish.intensity.heavy.desc"
|
||||
@@ -57,16 +50,38 @@ public enum PolishIntensity: String, Codable, Sendable, CaseIterable {
|
||||
/// non-ambiguous constraint per call.
|
||||
public var promptGuideline: String {
|
||||
switch self {
|
||||
case .off:
|
||||
return "Do not change the input at all. Output the original text verbatim."
|
||||
case .light:
|
||||
return "Only remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok) and obvious duplicated fragments. Do not change any other words, word order, or punctuation."
|
||||
return """
|
||||
Light rewrite: remove isolated filler words (嗯, 呃, 那个, 就是, 然后, 对, ok, um, uh) and obvious duplicated fragments only. \
|
||||
Do not rephrase otherwise-clear wording. \
|
||||
Still restore punctuation, sentence breaks, and content-triggered structure (lists, paragraphs) per the global output contract.
|
||||
"""
|
||||
case .medium:
|
||||
return "Correct obvious speech-recognition errors (homophones, missing/extra characters). Remove filler words and duplicated fragments. Adjust obviously-broken word order. Add punctuation. Do not restructure sentences, invent facts, or change the speaker's voice."
|
||||
return """
|
||||
Medium rewrite: fix obvious ASR errors (homophones, missing/extra characters), remove fillers and duplicated fragments, \
|
||||
adjust obviously-broken word order. Preserve the speaker's voice. \
|
||||
Still restore punctuation, sentence breaks, and content-triggered structure per the global output contract. \
|
||||
Do not invent facts or change numbers/proper nouns.
|
||||
"""
|
||||
case .heavy:
|
||||
return "Apply medium corrections, then optionally restructure: split long sentences, auto-number enumerated items into markdown lists, group related ideas into paragraphs. Preserve every fact, number, and proper noun."
|
||||
return """
|
||||
Heavy rewrite: apply medium corrections, then you may reorganize paragraphs, split long sentences, and listify enumerated content. \
|
||||
Punctuation and structure are mandatory at every intensity. \
|
||||
Preserve every fact, number, and proper noun. Do not add information.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/// Legacy persisted value `"off"` maps to `.medium` on read.
|
||||
public static func resolve(storedRawValue raw: String) -> PolishIntensity {
|
||||
if raw == legacyOffRawValue {
|
||||
return .medium
|
||||
}
|
||||
return PolishIntensity(rawValue: raw) ?? .default
|
||||
}
|
||||
|
||||
/// Raw value written by builds before the off tier was removed.
|
||||
public static let legacyOffRawValue = "off"
|
||||
}
|
||||
|
||||
extension PolishIntensity {
|
||||
|
||||
@@ -51,12 +51,21 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// "on" state during init, but new writes never touch the key.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let handednessPreference = "config.handednessPreference"
|
||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
// v0.3.0: how aggressively the LLM should rewrite transcripts.
|
||||
static let polishIntensity = "config.polishIntensity"
|
||||
}
|
||||
|
||||
@Published public var providerId: String {
|
||||
didSet { defaults.set(providerId, forKey: Key.providerId) }
|
||||
didSet {
|
||||
defaults.set(providerId, forKey: Key.providerId)
|
||||
// Keep API keys isolated per provider: switching provider in
|
||||
// Settings loads that provider's key instead of reusing the
|
||||
// previously selected vendor's key.
|
||||
isSyncingProviderAPIKey = true
|
||||
apiKey = Keychain.apiKey(for: providerId) ?? ""
|
||||
isSyncingProviderAPIKey = false
|
||||
}
|
||||
}
|
||||
@Published public var baseURL: String {
|
||||
didSet { defaults.set(baseURL, forKey: Key.baseURL) }
|
||||
@@ -65,9 +74,9 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
didSet {
|
||||
// Skip the round-trip on init — we read from Keychain and
|
||||
// writing the same value back is wasteful.
|
||||
guard oldValue != apiKey else { return }
|
||||
guard oldValue != apiKey, !isSyncingProviderAPIKey else { return }
|
||||
do {
|
||||
try Keychain.setAPIKey(apiKey)
|
||||
try Keychain.setAPIKey(apiKey, for: providerId)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [OSGKeyboard] Keychain write failed: \(error)")
|
||||
@@ -84,10 +93,13 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
@Published public var localeId: String {
|
||||
didSet { defaults.set(localeId, forKey: Key.localeId) }
|
||||
}
|
||||
/// "local" → on-device ASR only (raw transcript delivery).
|
||||
/// "cloud" → ASR + LLM polish (always on; modeId kept for compatibility).
|
||||
/// "local" → on-device ASR + built-in DeepSeek polish.
|
||||
/// "cloud" → on-device ASR + user's cloud LLM polish.
|
||||
@Published public var engineMode: String {
|
||||
didSet { defaults.set(engineMode, forKey: Key.engineMode) }
|
||||
didSet {
|
||||
defaults.set(engineMode, forKey: Key.engineMode)
|
||||
applyEngineModeSideEffects()
|
||||
}
|
||||
}
|
||||
@Published public var hasCompletedOnboarding: Bool {
|
||||
didSet {
|
||||
@@ -162,27 +174,25 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Cloud engine: any selected target locale. Local engine:
|
||||
/// only when cloud polish is also enabled.
|
||||
public var isTranslationEffective: Bool {
|
||||
guard translationEnabled else { return false }
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool {
|
||||
didSet {
|
||||
defaults.set(cursorDragNavigationEnabled, forKey: Key.cursorDragNavigationEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/// Translation picker visibility. Cloud engine: always. Local engine:
|
||||
/// only when "Cloud polish after ASR" is on — translation is a
|
||||
/// sub-step of that cloud LLM pass, not a standalone feature.
|
||||
public var isTranslationRowVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return isLocalEngine && localModeCloudPolishEnabled
|
||||
/// Whether the pipeline should run translate-and-polish (not just
|
||||
/// polish). Both engines honour the selected target locale.
|
||||
public var isTranslationEffective: Bool {
|
||||
translationEnabled
|
||||
}
|
||||
|
||||
/// Translation picker visibility — available on both engines.
|
||||
public var isTranslationRowVisible: Bool { true }
|
||||
|
||||
/// v0.3.0: how aggressively the LLM should rewrite the ASR
|
||||
/// transcript. Default is `medium` (Typeless-equivalent). The
|
||||
/// `off` value never calls the LLM — equivalent to "transcribe
|
||||
/// only" regardless of `engineMode`.
|
||||
/// transcript. Default is `medium` (Typeless-equivalent).
|
||||
@Published public var polishIntensity: PolishIntensity {
|
||||
didSet { defaults.set(polishIntensity.rawValue, forKey: Key.polishIntensity) }
|
||||
}
|
||||
@@ -200,32 +210,14 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
/// On-device ASR only; no cloud API required.
|
||||
public var isLocalEngine: Bool { engineMode == "local" }
|
||||
|
||||
/// Whether a transcript produced by the local engine should be
|
||||
/// sent through the cloud LLM polish step before insertion.
|
||||
///
|
||||
/// v0.2.0: the local engine defaults to ASR-only. When the user
|
||||
/// enables "Cloud polish after ASR" (`localModeCloudPolishEnabled`)
|
||||
/// we route the transcript through the configured LLM (DeepSeek by
|
||||
/// default in local mode) — same `PolishingService` code path the
|
||||
/// cloud engine uses.
|
||||
///
|
||||
/// If the user hasn't entered an API key we can't run the polish
|
||||
/// step; callers should check `Keychain.apiKey()` before invoking.
|
||||
public var shouldPolishLocalTranscript: Bool {
|
||||
isLocalEngine && localModeCloudPolishEnabled
|
||||
}
|
||||
/// Local engine always polishes via the built-in DeepSeek path.
|
||||
public var shouldPolishLocalTranscript: Bool { isLocalEngine }
|
||||
|
||||
/// v0.2.1 follow-up: when the local engine is using the cloud-
|
||||
/// polish step, route the call through DeepSeek — cheap, strong
|
||||
/// on Chinese, and the right default for the on-device ASR
|
||||
/// transcript. Other engines honor the user's configured
|
||||
/// `providerId` unchanged so cloud users keep their preferred
|
||||
/// vendor (OpenAI / Anthropic / Zhipu / etc).
|
||||
public var localModeProviderId: String {
|
||||
isLocalEngine ? "deepseek" : providerId
|
||||
}
|
||||
/// Cloud engine uses `providerId`. Local engine pins DeepSeek.
|
||||
public var localModeProviderId: String { "deepseek" }
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private var isSyncingProviderAPIKey = false
|
||||
|
||||
public init(defaults: UserDefaults? = nil) {
|
||||
let resolvedDefaults: UserDefaults = defaults ?? (AppGroup.isAvailable ? AppGroup.defaults : .standard)
|
||||
@@ -239,7 +231,7 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
// UserDefaults slot. After this runs once, `Key.apiKeyLegacy`
|
||||
// is empty in the suite and all subsequent reads go through the
|
||||
// Keychain.
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults)
|
||||
self.apiKey = ProviderConfig.resolveAPIKey(defaults: resolvedDefaults, providerId: pid)
|
||||
|
||||
self.model = resolvedDefaults.string(forKey: Key.model) ?? preset.defaultModel
|
||||
self.modeId = resolvedDefaults.string(forKey: Key.modeId) ?? "polish"
|
||||
@@ -278,12 +270,18 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
self.handednessPreference = HandednessPreference.fromStored(
|
||||
resolvedDefaults.string(forKey: Key.handednessPreference)
|
||||
)
|
||||
if resolvedDefaults.object(forKey: Key.cursorDragNavigationEnabled) == nil {
|
||||
self.cursorDragNavigationEnabled = true
|
||||
} else {
|
||||
self.cursorDragNavigationEnabled = resolvedDefaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
||||
}
|
||||
// v0.3.0: polish intensity. Default to `.medium` for new
|
||||
// installs and upgrades; the existing `off` / `light` /
|
||||
// `heavy` values are honored.
|
||||
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity),
|
||||
let intensity = PolishIntensity(rawValue: raw) {
|
||||
self.polishIntensity = intensity
|
||||
// installs; legacy `"off"` migrates to `.medium`.
|
||||
if let raw = resolvedDefaults.string(forKey: Key.polishIntensity) {
|
||||
self.polishIntensity = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
resolvedDefaults.set(PolishIntensity.medium.rawValue, forKey: Key.polishIntensity)
|
||||
}
|
||||
} else {
|
||||
self.polishIntensity = .default
|
||||
}
|
||||
@@ -292,17 +290,36 @@ public final class ProviderConfig: ObservableObject, @unchecked Sendable {
|
||||
if self.engineMode == "cloud", self.modeId != "polish" {
|
||||
self.modeId = "polish"
|
||||
}
|
||||
// DeepSeek is local-engine only — never a cloud picker choice.
|
||||
if self.engineMode == "cloud", self.providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep cloud vs local provider choices isolated when the user
|
||||
/// switches engines in Settings / onboarding.
|
||||
private func applyEngineModeSideEffects() {
|
||||
if engineMode == "cloud", providerId == "deepseek" {
|
||||
apply(preset: LLMProvider.provider(id: "openai"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the API key from the Keychain, falling back to a one-time
|
||||
/// migration from the legacy UserDefaults slot.
|
||||
private static func resolveAPIKey(defaults: UserDefaults) -> String {
|
||||
if let stored = Keychain.apiKey(), !stored.isEmpty {
|
||||
private static func resolveAPIKey(defaults: UserDefaults, providerId: String) -> String {
|
||||
if let stored = Keychain.apiKey(for: providerId), !stored.isEmpty {
|
||||
return stored
|
||||
}
|
||||
// Migration path: old builds stored one global key under
|
||||
// Keychain account "current". Move it to the active provider.
|
||||
if let legacyKeychain = Keychain.legacyAPIKey(), !legacyKeychain.isEmpty {
|
||||
try? Keychain.setAPIKey(legacyKeychain, for: providerId)
|
||||
try? Keychain.deleteLegacyAPIKey()
|
||||
return legacyKeychain
|
||||
}
|
||||
if let legacy = defaults.string(forKey: Key.apiKeyLegacy),
|
||||
!legacy.isEmpty {
|
||||
try? Keychain.setAPIKey(legacy)
|
||||
try? Keychain.setAPIKey(legacy, for: providerId)
|
||||
defaults.removeObject(forKey: Key.apiKeyLegacy)
|
||||
return legacy
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
// computed shim for source compatibility.
|
||||
static let translationTargetLocaleId = "config.translationTargetLocaleId"
|
||||
static let handednessPreference = "config.handednessPreference"
|
||||
// Drag pads beside the mic move the caret like arrow keys.
|
||||
static let cursorDragNavigationEnabled = "config.cursorDragNavigationEnabled"
|
||||
// v0.3.0: polish intensity (off / light / medium / heavy).
|
||||
static let polishIntensity = "config.polishIntensity"
|
||||
// v0.3.0: last app context detected by the keyboard extension.
|
||||
@@ -70,7 +72,7 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
/// Returns "" when nothing is stored so the LLMClient can surface a
|
||||
/// `noAPIKey` error rather than firing off an obviously-bad request.
|
||||
public var apiKey: String {
|
||||
Keychain.apiKey() ?? ""
|
||||
Keychain.apiKey(for: providerId) ?? ""
|
||||
}
|
||||
|
||||
public var model: String {
|
||||
@@ -137,6 +139,15 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
HandednessPreference.fromStored(defaults.string(forKey: Key.handednessPreference))
|
||||
}
|
||||
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
/// Defaults to `true` for new installs.
|
||||
public var cursorDragNavigationEnabled: Bool {
|
||||
guard defaults.object(forKey: Key.cursorDragNavigationEnabled) != nil else {
|
||||
return true
|
||||
}
|
||||
return defaults.bool(forKey: Key.cursorDragNavigationEnabled)
|
||||
}
|
||||
|
||||
// MARK: - Writes
|
||||
|
||||
public func setModeId(_ id: String) {
|
||||
@@ -187,29 +198,30 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Whether ASR output should be sent through the cloud LLM step.
|
||||
/// Cloud engine: always. Local engine: only when cloud polish is
|
||||
/// enabled (translation is a sub-option of that step).
|
||||
public var shouldRunCloudLLMStep: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
public func setCursorDragNavigationEnabled(_ enabled: Bool) {
|
||||
defaults.set(enabled, forKey: Key.cursorDragNavigationEnabled)
|
||||
AppGroupConfigDarwin.postConfigChanged()
|
||||
}
|
||||
|
||||
/// Whether ASR output should be sent through the LLM polish step.
|
||||
/// Both engines always run polish after ASR completes (chunked
|
||||
/// pipeline stitches first). Ultra-short structure-free utterances
|
||||
/// may skip the LLM inside `PolishingService`.
|
||||
public var shouldRunCloudLLMStep: Bool { true }
|
||||
|
||||
/// Whether translate-and-polish should run (vs polish-only).
|
||||
public var isTranslationEffective: Bool {
|
||||
guard translationEnabled else { return false }
|
||||
if engineMode == "local" { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
translationEnabled
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
/// Cloud engine: always. Local engine: when cloud polish is enabled
|
||||
/// (translation is a sub-option of that LLM step). Independent of
|
||||
/// whether a target locale is currently selected — the chip stays
|
||||
/// visible so the user can pick "不翻译" or a language in-place.
|
||||
public var isTranslationChipVisible: Bool {
|
||||
if engineMode == "cloud" { return true }
|
||||
return localModeCloudPolishEnabled
|
||||
public var isTranslationChipVisible: Bool { true }
|
||||
|
||||
/// Cloud engine requires a provider-specific API key before the user
|
||||
/// can start voice input. Local engine uses the built-in DeepSeek path.
|
||||
public var isCloudAPIKeyMissingForVoiceInput: Bool {
|
||||
guard engineMode == "cloud" else { return false }
|
||||
return apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
|
||||
/// Polish vs translate-and-polish for the active pipeline.
|
||||
@@ -230,10 +242,14 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
/// How aggressively the LLM should rewrite the ASR transcript.
|
||||
/// Defaults to `medium` for new installs.
|
||||
public var polishIntensity: PolishIntensity {
|
||||
guard let raw = defaults.string(forKey: Key.polishIntensity),
|
||||
let value = PolishIntensity(rawValue: raw)
|
||||
else { return .default }
|
||||
return value
|
||||
guard let raw = defaults.string(forKey: Key.polishIntensity) else {
|
||||
return .default
|
||||
}
|
||||
let resolved = PolishIntensity.resolve(storedRawValue: raw)
|
||||
if raw == PolishIntensity.legacyOffRawValue {
|
||||
defaults.set(resolved.rawValue, forKey: Key.polishIntensity)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
public func setPolishIntensity(_ intensity: PolishIntensity) {
|
||||
@@ -296,7 +312,17 @@ public struct AppGroupStore: @unchecked Sendable {
|
||||
return .empty
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
var dictionary = try JSONDecoder().decode(PersonalDictionary.self, from: data)
|
||||
if dictionary.entries.contains(where: { $0.source == .history }) {
|
||||
for index in dictionary.entries.indices where dictionary.entries[index].source == .history {
|
||||
dictionary.entries[index].source = .manual
|
||||
}
|
||||
dictionary.version += 1
|
||||
if let migrated = try? JSONEncoder().encode(dictionary) {
|
||||
defaults.set(migrated, forKey: Key.personalDictionary)
|
||||
}
|
||||
}
|
||||
return dictionary
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("⚠️ [AppGroupStore] personalDictionary decode failed: \(error)")
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
// CursorNavigation.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure helpers for moving the text caret from the keyboard extension.
|
||||
// Horizontal moves are character-accurate. Vertical moves jump between
|
||||
// *visual* lines — hard `\n` breaks and soft wraps.
|
||||
//
|
||||
// First-principles note: a keyboard extension only sees a bounded text
|
||||
// window (`documentContext{Before,After}Input`) and can only actuate via
|
||||
// `adjustTextPosition(byCharacterOffset:)`. It has NO access to the host
|
||||
// field's font, width, or caret rect, so soft-wrap positions are
|
||||
// fundamentally unknowable and must be *estimated*. We reduce the visible
|
||||
// error two ways: (1) exact handling of hard `\n`; (2) an injectable
|
||||
// per-character width so the extension can feed real font metrics (killing
|
||||
// the i-vs-W column drift that a fixed 1/2 table causes). The wrap width
|
||||
// itself stays a calibrated estimate.
|
||||
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
public enum CursorNavigation {
|
||||
|
||||
/// Advance width of a single character, in an arbitrary but consistent
|
||||
/// unit (points when backed by real font metrics; abstract "units" for
|
||||
/// the built-in default). Must be paired with a `lineWidth` in the same
|
||||
/// unit.
|
||||
public typealias CharacterWidth = @Sendable (Character) -> CGFloat
|
||||
|
||||
// MARK: - Layout config
|
||||
|
||||
/// Describes how text wraps into visual lines. `lineWidth` and the values
|
||||
/// returned by `widthOf` must share the same unit.
|
||||
public struct VisualLineLayoutConfig: Sendable {
|
||||
/// Wrap threshold: max total width of one visual line.
|
||||
public let lineWidth: CGFloat
|
||||
/// Per-character advance width provider.
|
||||
public let widthOf: CharacterWidth
|
||||
|
||||
public init(
|
||||
lineWidth: CGFloat,
|
||||
widthOf: @escaping CharacterWidth = CursorNavigation.defaultDisplayWidth
|
||||
) {
|
||||
self.lineWidth = max(1, lineWidth)
|
||||
self.widthOf = widthOf
|
||||
}
|
||||
|
||||
/// Conservative default when no field width is known.
|
||||
public static let fallback = VisualLineLayoutConfig(lineWidth: 44)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Legacy logical column (chars since last `\n`). Kept for tests.
|
||||
public static func column(before: String?) -> Int {
|
||||
guard let before, !before.isEmpty else { return 0 }
|
||||
if let lastNewline = before.lastIndex(of: "\n") {
|
||||
return before.distance(from: before.index(after: lastNewline), to: before.endIndex)
|
||||
}
|
||||
return before.count
|
||||
}
|
||||
|
||||
/// Display-column offset (in `widthOf` units) on the current visual line.
|
||||
public static func visualDisplayColumn(
|
||||
before: String?,
|
||||
after: String?,
|
||||
config: VisualLineLayoutConfig
|
||||
) -> CGFloat {
|
||||
let text = mergedContext(before: before, after: after)
|
||||
let cursor = before?.count ?? 0
|
||||
let layout = VisualLineLayout(text: text, config: config)
|
||||
let lineStart = layout.lineStart(containing: cursor)
|
||||
return layout.width(from: lineStart, to: cursor)
|
||||
}
|
||||
|
||||
/// One visual line up. Returns caret offset and the display column to
|
||||
/// keep sticky for the rest of this vertical drag.
|
||||
public static func visualLineUpOffset(
|
||||
before: String?,
|
||||
after: String?,
|
||||
preferredDisplayColumn: CGFloat?,
|
||||
config: VisualLineLayoutConfig
|
||||
) -> (offset: Int, stickyColumn: CGFloat)? {
|
||||
let text = mergedContext(before: before, after: after)
|
||||
let cursor = before?.count ?? 0
|
||||
let layout = VisualLineLayout(text: text, config: config)
|
||||
|
||||
guard let currentLine = layout.lineIndex(containing: cursor), currentLine > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sticky = preferredDisplayColumn
|
||||
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
|
||||
let previousStart = layout.lineStarts[currentLine - 1]
|
||||
let previousEnd = layout.lineStarts[currentLine]
|
||||
let target = layout.offset(
|
||||
onLineStartingAt: previousStart,
|
||||
lineEndingBefore: previousEnd,
|
||||
displayColumn: sticky
|
||||
)
|
||||
let offset = target - cursor
|
||||
guard offset != 0 else { return nil }
|
||||
return (offset, sticky)
|
||||
}
|
||||
|
||||
/// One visual line down.
|
||||
public static func visualLineDownOffset(
|
||||
before: String?,
|
||||
after: String?,
|
||||
preferredDisplayColumn: CGFloat?,
|
||||
config: VisualLineLayoutConfig
|
||||
) -> (offset: Int, stickyColumn: CGFloat)? {
|
||||
let text = mergedContext(before: before, after: after)
|
||||
let cursor = before?.count ?? 0
|
||||
let layout = VisualLineLayout(text: text, config: config)
|
||||
|
||||
guard let currentLine = layout.lineIndex(containing: cursor) else { return nil }
|
||||
guard currentLine + 1 < layout.lineStarts.count else { return nil }
|
||||
|
||||
let sticky = preferredDisplayColumn
|
||||
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
|
||||
let nextStart = layout.lineStarts[currentLine + 1]
|
||||
let nextEnd = currentLine + 2 < layout.lineStarts.count
|
||||
? layout.lineStarts[currentLine + 2]
|
||||
: text.count
|
||||
let target = layout.offset(
|
||||
onLineStartingAt: nextStart,
|
||||
lineEndingBefore: nextEnd,
|
||||
displayColumn: sticky
|
||||
)
|
||||
let offset = target - cursor
|
||||
guard offset != 0 else { return nil }
|
||||
return (offset, sticky)
|
||||
}
|
||||
|
||||
// MARK: - Default width table
|
||||
|
||||
/// Crude fallback advance width: wide scripts count double, everything
|
||||
/// else single. Used by tests and when real metrics are unavailable.
|
||||
public static func defaultDisplayWidth(_ character: Character) -> CGFloat {
|
||||
guard let scalar = character.unicodeScalars.first else { return 1 }
|
||||
if character == "\n" { return 0 }
|
||||
if character == "\t" { return 4 }
|
||||
if isWide(scalar) { return 2 }
|
||||
return 1
|
||||
}
|
||||
|
||||
private static func isWide(_ scalar: UnicodeScalar) -> Bool {
|
||||
let value = scalar.value
|
||||
return (0x1100...0x115F).contains(value) // Hangul Jamo
|
||||
|| (0x2E80...0xA4CF).contains(value) // CJK radicals, symbols, bopomofo, yi
|
||||
|| (0xAC00...0xD7A3).contains(value) // Hangul syllables
|
||||
|| (0xF900...0xFAFF).contains(value) // CJK compatibility
|
||||
|| (0xFE10...0xFE1F).contains(value) // vertical forms
|
||||
|| (0xFE30...0xFE6F).contains(value) // CJK compatibility forms
|
||||
|| (0xFF00...0xFF60).contains(value) // fullwidth
|
||||
|| (0xFFE0...0xFFE6).contains(value) // fullwidth symbols
|
||||
|| (0x20000...0x2FFFF).contains(value) // CJK extension planes
|
||||
|| (0x30000...0x3FFFF).contains(value)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func mergedContext(before: String?, after: String?) -> String {
|
||||
(before ?? "") + (after ?? "")
|
||||
}
|
||||
|
||||
// MARK: - Visual line layout
|
||||
|
||||
struct VisualLineLayout {
|
||||
let text: String
|
||||
let widthOf: CharacterWidth
|
||||
let lineStarts: [Int]
|
||||
|
||||
init(text: String, config: VisualLineLayoutConfig) {
|
||||
self.text = text
|
||||
self.widthOf = config.widthOf
|
||||
self.lineStarts = Self.computeLineStarts(
|
||||
in: text,
|
||||
maxWidth: config.lineWidth,
|
||||
widthOf: config.widthOf
|
||||
)
|
||||
}
|
||||
|
||||
func lineIndex(containing offset: Int) -> Int? {
|
||||
guard !lineStarts.isEmpty else { return nil }
|
||||
for index in lineStarts.indices.reversed() where offset >= lineStarts[index] {
|
||||
return index
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lineStart(containing offset: Int) -> Int {
|
||||
lineIndex(containing: offset).map { lineStarts[$0] } ?? 0
|
||||
}
|
||||
|
||||
func width(from start: Int, to end: Int) -> CGFloat {
|
||||
guard start < end, end <= text.count else { return 0 }
|
||||
let startIndex = text.index(text.startIndex, offsetBy: start)
|
||||
let endIndex = text.index(text.startIndex, offsetBy: end)
|
||||
var total: CGFloat = 0
|
||||
var index = startIndex
|
||||
while index < endIndex {
|
||||
total += widthOf(text[index])
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func offset(
|
||||
onLineStartingAt lineStart: Int,
|
||||
lineEndingBefore lineEnd: Int,
|
||||
displayColumn: CGFloat
|
||||
) -> Int {
|
||||
guard lineStart <= lineEnd, lineEnd <= text.count else { return lineStart }
|
||||
let startIndex = text.index(text.startIndex, offsetBy: lineStart)
|
||||
let endIndex = text.index(text.startIndex, offsetBy: lineEnd)
|
||||
var total: CGFloat = 0
|
||||
var index = startIndex
|
||||
while index < endIndex {
|
||||
let advance = widthOf(text[index])
|
||||
if total + advance > displayColumn { break }
|
||||
total += advance
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return text.distance(from: text.startIndex, to: index)
|
||||
}
|
||||
|
||||
private static func computeLineStarts(
|
||||
in text: String,
|
||||
maxWidth: CGFloat,
|
||||
widthOf: CharacterWidth
|
||||
) -> [Int] {
|
||||
guard !text.isEmpty else { return [0] }
|
||||
|
||||
var starts: [Int] = [0]
|
||||
var lineWidth: CGFloat = 0
|
||||
var lineStart = text.startIndex
|
||||
var lastBreak: String.Index?
|
||||
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
|
||||
if character == "\n" {
|
||||
let next = text.index(after: index)
|
||||
let nextOffset = text.distance(from: text.startIndex, to: next)
|
||||
if starts.last != nextOffset {
|
||||
starts.append(nextOffset)
|
||||
}
|
||||
lineStart = next
|
||||
lineWidth = 0
|
||||
lastBreak = nil
|
||||
index = next
|
||||
continue
|
||||
}
|
||||
|
||||
let advance = widthOf(character)
|
||||
if character == " " || character == "\t" {
|
||||
lastBreak = index
|
||||
}
|
||||
|
||||
if lineWidth + advance > maxWidth, index > lineStart {
|
||||
let breakIndex: String.Index
|
||||
if let lastBreak, lastBreak > lineStart {
|
||||
breakIndex = text.index(after: lastBreak)
|
||||
} else {
|
||||
breakIndex = index
|
||||
}
|
||||
let breakOffset = text.distance(from: text.startIndex, to: breakIndex)
|
||||
if starts.last != breakOffset {
|
||||
starts.append(breakOffset)
|
||||
}
|
||||
lineStart = breakIndex
|
||||
lineWidth = 0
|
||||
lastBreak = nil
|
||||
if breakIndex == index {
|
||||
lineWidth = advance
|
||||
index = text.index(after: index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
lineWidth += advance
|
||||
index = text.index(after: index)
|
||||
}
|
||||
|
||||
return starts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
/// Real-font per-character advance widths (in points) for cursor visual-line
|
||||
/// navigation. Caches measurements so repeated drag samples are cheap.
|
||||
///
|
||||
/// Absolute values assume a ~17 pt body font; only the *ratios* between
|
||||
/// glyphs (and between a glyph and the field width) matter for column
|
||||
/// fidelity, so a reference font is sufficient to eliminate the fixed-width
|
||||
/// column drift.
|
||||
///
|
||||
/// Not actor-isolated on purpose: the width closure is invoked synchronously
|
||||
/// from the nonisolated `CursorNavigation` layout code. A lock guards the
|
||||
/// cache so `@unchecked Sendable` is safe.
|
||||
public final class CursorGlyphMetrics: @unchecked Sendable {
|
||||
public static let shared = CursorGlyphMetrics()
|
||||
|
||||
private let font = UIFont.systemFont(ofSize: 17)
|
||||
private let lock = NSLock()
|
||||
private var cache: [Character: CGFloat] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func width(of character: Character) -> CGFloat {
|
||||
if character == "\n" { return 0 }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let cached = cache[character] { return cached }
|
||||
let measured = (String(character) as NSString)
|
||||
.size(withAttributes: [.font: font])
|
||||
.width
|
||||
let width = measured > 0 ? measured : font.pointSize * 0.5
|
||||
cache[character] = width
|
||||
return width
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -71,6 +71,11 @@ public final class KeyboardState: ObservableObject {
|
||||
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
|
||||
/// Whether the host app's Flow voice session is currently valid.
|
||||
@Published public var flowSessionActive: Bool = false
|
||||
/// When true, the mic is intentionally disabled (e.g. cloud engine
|
||||
/// selected but the provider-specific API key is missing).
|
||||
@Published public var micDisabled: Bool = false
|
||||
/// One-line helper shown above the mic while `micDisabled == true`.
|
||||
@Published public var micDisabledHint: String = ""
|
||||
/// "local" → on-device ASR only. "cloud" → ASR + LLM polish.
|
||||
@Published public var engineMode: String = "cloud"
|
||||
/// Which on-device ASR engine to use when `engineMode == "local"`.
|
||||
@@ -98,24 +103,23 @@ public final class KeyboardState: ObservableObject {
|
||||
/// Defaults to `offLocaleId` so the keyboard boots in the "off"
|
||||
/// state on first install.
|
||||
@Published public var translationTargetLocaleId: String = TranslationLanguageCatalog.offLocaleId
|
||||
/// v0.2.0: mirrored from App Group — local engine runs the cloud
|
||||
/// LLM step only when this is `true`.
|
||||
@Published public var localModeCloudPolishEnabled: Bool = false
|
||||
/// v0.2.0: mirrored from App Group — kept for source compatibility.
|
||||
/// Local engine always runs built-in polish; the flag is ignored.
|
||||
@Published public var localModeCloudPolishEnabled: Bool = true
|
||||
/// Mirrored from App Group — swaps delete / return on the bottom row.
|
||||
@Published public var handednessPreference: HandednessPreference = .left
|
||||
/// Whether translate-and-polish is actually armed for the current
|
||||
/// engine (local requires cloud polish + a target locale).
|
||||
/// Press-and-drag pads beside the mic for four-way caret movement.
|
||||
@Published public var cursorDragNavigationEnabled: Bool = true
|
||||
/// `true` while a cursor-drag pad is being pressed — drives the hint
|
||||
/// shown above the mic.
|
||||
@Published public var cursorDragActive: Bool = false
|
||||
/// Whether translate-and-polish is armed for the current engine.
|
||||
public var isTranslationEffective: Bool {
|
||||
guard translationEnabled else { return false }
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
translationEnabled
|
||||
}
|
||||
|
||||
/// Whether the keyboard top-bar translation chip should render.
|
||||
public var isTranslationChipVisible: Bool {
|
||||
if isLocalEngine { return localModeCloudPolishEnabled }
|
||||
return true
|
||||
}
|
||||
public var isTranslationChipVisible: Bool { true }
|
||||
|
||||
/// Convenience shorthand used by the pipeline and views.
|
||||
public var isLocalEngine: Bool { engineMode == "local" }
|
||||
@@ -168,6 +172,11 @@ public final class KeyboardState: ObservableObject {
|
||||
public var insertNewline: () -> Void = {}
|
||||
public var insertSpace: () -> Void = {}
|
||||
public var deleteBackward: () -> Void = {}
|
||||
public var moveCursorHorizontal: (Int) -> Void = { _ in }
|
||||
public var moveCursorVertical: (Int) -> Void = { _ in }
|
||||
/// Cursor-drag pad press lifecycle — updates `cursorDragActive` and
|
||||
/// lets the view controller reset vertical-navigation stickiness.
|
||||
public var setCursorDragActive: (Bool) -> Void = { _ in }
|
||||
|
||||
// MARK: - Preview helpers (DEBUG only)
|
||||
|
||||
|
||||
@@ -39,18 +39,25 @@ public enum Keychain: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private static let service = "com.osgkeyboard.apikey"
|
||||
private static let account = "current"
|
||||
private static let legacyAccount = "current"
|
||||
private static let defaultProviderId = "openai"
|
||||
|
||||
private static func account(for providerId: String) -> String {
|
||||
let trimmed = providerId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalized = trimmed.isEmpty ? defaultProviderId : trimmed.lowercased()
|
||||
return "provider.\(normalized)"
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
/// Read the stored API key. Returns `nil` when nothing is stored,
|
||||
/// or when the underlying call returns a non-success status we can't
|
||||
/// usefully surface (e.g. transient `errSecInteractionNotAllowed`).
|
||||
public static func apiKey() -> String? {
|
||||
public static func apiKey(for providerId: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
@@ -73,21 +80,45 @@ public enum Keychain: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible shorthand for the default cloud provider.
|
||||
public static func apiKey() -> String? {
|
||||
apiKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
/// Legacy account used by older builds before provider-scoped keys.
|
||||
/// New code should avoid this and use `apiKey(for:)`.
|
||||
public static func legacyAPIKey() -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: legacyAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let str = String(data: data, encoding: .utf8)
|
||||
else { return nil }
|
||||
return str
|
||||
}
|
||||
|
||||
// MARK: - Write
|
||||
|
||||
/// Store (or update) the API key. An empty string deletes the entry,
|
||||
/// so clearing the field in the UI removes the key from the Keychain
|
||||
/// rather than leaving an empty-string placeholder.
|
||||
public static func setAPIKey(_ key: String) throws {
|
||||
public static func setAPIKey(_ key: String, for providerId: String) throws {
|
||||
if key.isEmpty {
|
||||
try deleteAPIKey()
|
||||
try deleteAPIKey(for: providerId)
|
||||
return
|
||||
}
|
||||
let data = Data(key.utf8)
|
||||
let baseQuery: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
]
|
||||
// Try update first — covers the common path where the key already
|
||||
// exists (every settings edit after the first).
|
||||
@@ -112,13 +143,18 @@ public enum Keychain: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible shorthand for the default cloud provider.
|
||||
public static func setAPIKey(_ key: String) throws {
|
||||
try setAPIKey(key, for: defaultProviderId)
|
||||
}
|
||||
|
||||
// MARK: - Delete
|
||||
|
||||
public static func deleteAPIKey() throws {
|
||||
public static func deleteAPIKey(for providerId: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: account,
|
||||
kSecAttrAccount as String: account(for: providerId),
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
// `errSecItemNotFound` is success-from-the-user's-perspective — the
|
||||
@@ -127,4 +163,21 @@ public enum Keychain: @unchecked Sendable {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Backward-compatible shorthand for the default cloud provider.
|
||||
public static func deleteAPIKey() throws {
|
||||
try deleteAPIKey(for: defaultProviderId)
|
||||
}
|
||||
|
||||
public static func deleteLegacyAPIKey() throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: legacyAccount,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
if status != errSecSuccess && status != errSecItemNotFound {
|
||||
throw KeychainError.unexpectedStatus(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,25 @@ public enum LLMError: Error, LocalizedError, Sendable, Equatable {
|
||||
}
|
||||
|
||||
public protocol LLMClient: Sendable {
|
||||
func polish(_ text: String, systemPrompt: String) async throws -> String
|
||||
/// Polish `text` with `systemPrompt`. `timeout` overrides the
|
||||
/// per-request HTTP timeout for this call; when `nil` the client's
|
||||
/// `requestTimeout` baseline is used. Long transcripts must pass a
|
||||
/// larger, length-scaled timeout so the HTTP request is not cut off
|
||||
/// mid-generation (see `PolishingService.effectiveTimeout`).
|
||||
func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String
|
||||
|
||||
/// Single source of truth for the upper bound on a single LLM HTTP
|
||||
/// round-trip. Both the `URLRequest` we send and any wrapping
|
||||
/// timeout-style race (e.g. `PolishingService`'s `withThrowingTaskGroup`)
|
||||
/// must read from this property so the two never disagree.
|
||||
/// Baseline upper bound for a single LLM HTTP round-trip when no
|
||||
/// per-request `timeout` is supplied.
|
||||
var requestTimeout: TimeInterval { get }
|
||||
}
|
||||
|
||||
public extension LLMClient {
|
||||
/// Convenience overload that uses the baseline `requestTimeout`.
|
||||
func polish(_ text: String, systemPrompt: String) async throws -> String {
|
||||
try await polish(text, systemPrompt: systemPrompt, timeout: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - OpenAI-compatible implementation
|
||||
|
||||
public struct OpenAICompatibleClient: LLMClient {
|
||||
@@ -71,7 +81,7 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
self.session = session
|
||||
}
|
||||
|
||||
public func polish(_ text: String, systemPrompt: String) async throws -> String {
|
||||
public func polish(_ text: String, systemPrompt: String, timeout: TimeInterval?) async throws -> String {
|
||||
guard !apiKey.isEmpty else { throw LLMError.noAPIKey }
|
||||
|
||||
let urlString = baseURL.hasSuffix("/")
|
||||
@@ -93,7 +103,9 @@ public struct OpenAICompatibleClient: LLMClient {
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
req.timeoutInterval = requestTimeout
|
||||
// Per-request timeout scales with transcript length; fall back to
|
||||
// the baseline when the caller does not supply one.
|
||||
req.timeoutInterval = timeout ?? requestTimeout
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
req.httpBody = try encoder.encode(request)
|
||||
|
||||
@@ -10,24 +10,15 @@
|
||||
// English dictation while halving the network round-trip.
|
||||
//
|
||||
// Engine matrix:
|
||||
// - `engineMode == "cloud"` → always polish
|
||||
// - `engineMode == "local"`,
|
||||
// cloud polish disabled → ASR-only, return raw.
|
||||
// - `engineMode == "local"`,
|
||||
// cloud polish enabled → DeepSeek LLM step (polish or translate).
|
||||
// Translation uses `.translate` + `TranslationPrompt`; polish uses
|
||||
// the default system prompt. Missing preconfigured DeepSeek key
|
||||
// throws `missingAPIKey` and callers deliver raw + warning.
|
||||
// - `polishIntensity == .off` → ASR-only, return raw,
|
||||
// regardless of engine mode
|
||||
// - Missing API key → return raw + throw
|
||||
// `.missingAPIKey` so the caller can show the "fill in your key"
|
||||
// hint inline
|
||||
// - `engineMode == "cloud"` → on-device ASR, then user's cloud LLM
|
||||
// - `engineMode == "local"` → on-device ASR, then built-in DeepSeek
|
||||
// - Ultra-short, structure-free utterances skip the LLM entirely
|
||||
// - Cloud without API key → raw + `.missingAPIKey` warning
|
||||
// - Local without build key → raw + `.missingAPIKey` warning
|
||||
//
|
||||
// Caller-supplied `PolishContext` carries the per-call signals:
|
||||
// - `appContext` code / email / chat / document / unknown
|
||||
// - `intensity` off / light / medium / heavy (per-call
|
||||
// override; default is the user-configured value)
|
||||
// - `intensity` light / medium / heavy (per-call override)
|
||||
// - `precedingText` optional tail of the cursor's preceding text
|
||||
// for reference resolution
|
||||
//
|
||||
@@ -63,11 +54,11 @@ public actor PolishingService {
|
||||
/// one from `store.makeClient()` per call.
|
||||
private let injectedClient: LLMClient?
|
||||
|
||||
/// Default `timeout` is `LLMClient.requestTimeout + 1` second so the
|
||||
/// safety-net `withThrowingTaskGroup` never wins the race against
|
||||
/// the URL request itself; if the request times out cleanly the
|
||||
/// network error reaches us first. The +1 is the single point of
|
||||
/// slack between the two clocks — keep it here, not in `LLMClient`.
|
||||
/// `timeout` is the baseline (shortest) per-request HTTP timeout,
|
||||
/// used as the floor for `effectiveTimeout(for:)`. It defaults to the
|
||||
/// shared `LLMClient.requestTimeout`. The safety-net timer adds its
|
||||
/// own slack on top of the length-scaled budget in `polishRemote`, so
|
||||
/// no `+1` is baked in here.
|
||||
public init(
|
||||
store: AppGroupStore = AppGroupStore(),
|
||||
client: LLMClient? = nil,
|
||||
@@ -75,10 +66,10 @@ public actor PolishingService {
|
||||
) {
|
||||
self.store = store
|
||||
self.injectedClient = client
|
||||
self.timeout = timeout ?? (LLMClientFactory.defaultRequestTimeout + 1)
|
||||
self.timeout = timeout ?? LLMClientFactory.defaultRequestTimeout
|
||||
}
|
||||
|
||||
/// v0.3.0: context-aware polish entry point. The optional
|
||||
/// v0.3.0: context-aware polish entry point. The optional
|
||||
/// `PolishContext` carries per-call signals (app context,
|
||||
/// intensity, preceding text). Translation is a separate concept
|
||||
/// (see `mode` below) so callers wanting the v0.2.1 translate
|
||||
@@ -94,42 +85,38 @@ public actor PolishingService {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { throw PolishError.noTranscript }
|
||||
|
||||
// Resolve per-call context: per-call override wins over the
|
||||
// user-configured App Group value.
|
||||
let resolvedContext = resolveContext(override: context)
|
||||
|
||||
// "off" intensity never calls the LLM, regardless of engine
|
||||
// or mode. This lets users opt into "transcribe only" with
|
||||
// one tap without having to flip the engine mode or pick a
|
||||
// translation off-locale.
|
||||
if resolvedContext.intensity == .off {
|
||||
return trimmed
|
||||
// Ultra-short, structure-free inputs skip the LLM to save
|
||||
// latency (e.g. "好", "OK", "明天见").
|
||||
if mode == .polish,
|
||||
systemPrompt == nil || systemPrompt?.isEmpty == true,
|
||||
TranscriptPostProcessor.shouldSkipLLM(for: trimmed) {
|
||||
return TranscriptPostProcessor.localClean(trimmed)
|
||||
}
|
||||
|
||||
// Local engine + cloud-polish-off: pure ASR, no LLM.
|
||||
if store.engineMode == "local" {
|
||||
guard store.shouldRunCloudLLMStep else { return trimmed }
|
||||
} else {
|
||||
// Cloud engine needs an API key.
|
||||
if store.engineMode == "cloud", injectedClient == nil {
|
||||
guard !store.apiKey.isEmpty else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
}
|
||||
|
||||
return try await polishRemote(
|
||||
let llmResult = try await polishRemote(
|
||||
trimmed,
|
||||
mode: mode,
|
||||
systemPrompt: systemPrompt,
|
||||
providerIdOverride: providerIdOverride,
|
||||
context: resolvedContext
|
||||
)
|
||||
|
||||
// Translation and custom prompts bypass the polish post-processor.
|
||||
if mode != .polish || (systemPrompt != nil && !(systemPrompt?.isEmpty ?? true)) {
|
||||
return llmResult
|
||||
}
|
||||
|
||||
return TranscriptPostProcessor.process(original: trimmed, llmOutput: llmResult)
|
||||
}
|
||||
|
||||
/// Build the final `PolishContext` for this call. Per-call
|
||||
/// overrides take precedence; otherwise we read the user-configured
|
||||
/// values out of the App Group (so the keyboard extension's
|
||||
/// `PolishingService` instance does not need to know about
|
||||
/// `ProviderConfig`).
|
||||
private func resolveContext(override: PolishContext?) -> PolishContext {
|
||||
guard let override else {
|
||||
return PolishContext(
|
||||
@@ -137,10 +124,6 @@ public actor PolishingService {
|
||||
intensity: store.polishIntensity
|
||||
)
|
||||
}
|
||||
// If the override leaves a field at its default-when-nil
|
||||
// value, fall back to the App Group value. Today every
|
||||
// `PolishContext` field is non-optional so this branch
|
||||
// simply forwards; kept for future-proofing.
|
||||
return override
|
||||
}
|
||||
|
||||
@@ -151,12 +134,10 @@ public actor PolishingService {
|
||||
providerIdOverride: String? = nil,
|
||||
context: PolishContext
|
||||
) async throws -> String {
|
||||
// v0.2.1 follow-up: when the caller pins a provider id (the
|
||||
// local engine pins DeepSeek) we still want to honor the
|
||||
// injected test client, but we have to re-derive the
|
||||
// preset/baseURL/model/apiKey quartet from the *override* so
|
||||
// the injected client gets the right values when it's nil.
|
||||
let effectiveProviderId = providerIdOverride ?? store.providerId
|
||||
let effectiveProviderId = Self.resolvedProviderId(
|
||||
store: store,
|
||||
providerIdOverride: providerIdOverride
|
||||
)
|
||||
let client: LLMClient
|
||||
if let injectedClient {
|
||||
client = injectedClient
|
||||
@@ -169,32 +150,27 @@ public actor PolishingService {
|
||||
)
|
||||
let apiKey: String
|
||||
if effectiveProviderId == "deepseek" {
|
||||
let preconfigured = PreconfiguredKeys.deepseek
|
||||
if preconfigured == "TODO_FILL_LATER_DEEPSEEK_KEY" {
|
||||
// Placeholder still in place — refuse the round-
|
||||
// trip so the UI can surface a "build not
|
||||
// configured" hint instead of a 401.
|
||||
guard PreconfiguredKeys.isDeepseekConfigured else {
|
||||
throw PolishError.missingAPIKey
|
||||
}
|
||||
apiKey = preconfigured
|
||||
apiKey = PreconfiguredKeys.deepseek
|
||||
} else {
|
||||
apiKey = store.apiKey
|
||||
}
|
||||
client = OpenAICompatibleClient(baseURL: baseURL, apiKey: apiKey, model: model)
|
||||
}
|
||||
// Polish-mode callers (and translation-mode callers that
|
||||
// haven't supplied an explicit override) get the new
|
||||
// "intelligent" prompt that uses `PolishContext.appContext`,
|
||||
// `intensity`, and the personal dictionary. Translation-mode
|
||||
// callers keep the v0.2.1 `TranslationPrompt` path so the
|
||||
// translate-and-polish output contract doesn't change.
|
||||
|
||||
let prompt: String
|
||||
if let override = systemPrompt, !override.isEmpty {
|
||||
prompt = override
|
||||
} else {
|
||||
switch mode {
|
||||
case .polish:
|
||||
prompt = buildPrompt(for: trimmed, context: context)
|
||||
prompt = buildPrompt(
|
||||
for: trimmed,
|
||||
context: context,
|
||||
providerId: effectiveProviderId
|
||||
)
|
||||
case .translate(let targetLocaleId):
|
||||
let target = TranslationLanguageCatalog.resolve(targetLocaleId)
|
||||
prompt = TranslationPrompt.make(
|
||||
@@ -205,13 +181,17 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
let budget = effectiveTimeout(for: trimmed)
|
||||
// The HTTP request itself uses `budget`; the safety-net timer is
|
||||
// given a small slack on top so a clean URL timeout surfaces its
|
||||
// (more specific) transport error before the race fires.
|
||||
let safetyNet = budget + 2
|
||||
|
||||
return try await withThrowingTaskGroup(of: String.self) { group in
|
||||
group.addTask {
|
||||
try await client.polish(trimmed, systemPrompt: prompt)
|
||||
try await client.polish(trimmed, systemPrompt: prompt, timeout: budget)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(budget * 1_000_000_000))
|
||||
try await Task.sleep(nanoseconds: UInt64(safetyNet * 1_000_000_000))
|
||||
throw PolishError.timeout
|
||||
}
|
||||
let result = try await group.next()!
|
||||
@@ -220,91 +200,120 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the one-step "intelligent" prompt. The structure is:
|
||||
/// 1. Role
|
||||
/// 2. Three numbered tasks (correction, polish, style)
|
||||
/// 3. Hard rules (do-not-modify list, length cap, short-circuit)
|
||||
/// 4. User dictionary block (if any)
|
||||
/// 5. Context + intensity guidelines
|
||||
/// 6. Optional preceding text
|
||||
/// 7. The transcript to process
|
||||
/// 8. Output contract
|
||||
///
|
||||
/// The Chinese / English split mirrors `shouldUseChineseGuidance` so
|
||||
/// the polish step stays in the provider's strongest language.
|
||||
internal func buildPrompt(for text: String, context: PolishContext) -> String {
|
||||
/// Shared output contract injected into every polish prompt.
|
||||
internal static func globalOutputContract(useChinese: Bool) -> String {
|
||||
if useChinese {
|
||||
return """
|
||||
## 全局输出契约(所有润色档位均必须遵守,优先级最高)
|
||||
1. **禁止新增 emoji**:原文无 emoji 时输出不得出现 emoji;原文有 emoji 时仅可原样保留。
|
||||
2. **必须恢复合理标点**:逗号、句号、问号、感叹号;按语义分句,不要输出无标点长段。
|
||||
3. **必须做内容触发型结构化**(所有档位):
|
||||
- 「第一点/第二个/步骤一/一是二是三是」→ 转为 `1. ` 编号列表并换行
|
||||
- 「首先/其次/最后/另外/一方面」→ 分段换行,不强行编号
|
||||
- 待办、会议纪要、多个问题、长文本多句 → 按语义分段
|
||||
- 短但含结构信号的文本仍要格式化;极短且无结构的已由系统跳过
|
||||
4. **数字要结合上下文判断**(重要):
|
||||
- 有意义的数字(价格、日期、数量、时间、电话、版本号)→ 保持不变
|
||||
- 但语音里的序号常被误识别成数字或时间,需结合上下文修回并列表化:
|
||||
· 已出现「第一点」,随后的「第2:00 / 第2点0 / 第二零零」多半是「第二点」,「第3:00」多半是「第三点」
|
||||
· 「1、2、3」「一、二、三」在列举语境里就是序号,转成 `1. ` 列表
|
||||
- 判断依据是上下文里是否在“分点/列举”,不要机械地保留听错的数字
|
||||
5. **保守改写**:能加标点就不改词;能分段就不重写;能小改就不大改;不新增事实。
|
||||
6. **不改**人名、地名、专有名词(除非 ASR 明显错误)。
|
||||
7. 输出语言必须与原文一致;不翻译、不扩写成 AI 文案。
|
||||
8. 只输出最终文本:不要解释、不要引号包裹、不要前缀说明。
|
||||
"""
|
||||
} else {
|
||||
return """
|
||||
## Global output contract (mandatory at every intensity — highest priority)
|
||||
1. **No new emojis**: if the original has none, output must have none; preserve originals only.
|
||||
2. **Restore proper punctuation**: commas, periods, question marks; break run-on speech into sentences.
|
||||
3. **Content-triggered structure** (every intensity):
|
||||
- "first point / second / step one / one is two is three" → numbered `1. ` list with line breaks
|
||||
- "firstly / secondly / finally / on the other hand" → paragraph breaks, not forced numbering
|
||||
- todos, meeting notes, multiple questions, long multi-clause speech → semantic paragraphs
|
||||
4. **Judge numbers by context** (important):
|
||||
- Meaningful numbers (prices, dates, quantities, times, phone numbers, versions) → keep unchanged.
|
||||
- But spoken ordinals are often misrecognized as digits/times; use context to restore and listify:
|
||||
· after a "first point", a following "2:00 / point 2 / two oh oh" is likely "second point", "3:00" is "third point"
|
||||
· "1, 2, 3" or "one, two, three" in an enumerating context are ordinals → convert to a `1. ` list
|
||||
- Decide by whether the context is enumerating; do not mechanically preserve a misheard number.
|
||||
5. **Conservative rewrite**: prefer punctuation over rewording; prefer breaks over rewriting; minimal changes.
|
||||
6. **Do not** alter person names, places, or proper nouns unless clearly misrecognized.
|
||||
7. Output language must match the input; do not translate or expand into marketing copy.
|
||||
8. Output the final text only: no explanation, no quotes, no preamble.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
internal func buildPrompt(
|
||||
for text: String,
|
||||
context: PolishContext,
|
||||
providerId: String
|
||||
) -> String {
|
||||
let dictionary = store.personalDictionary
|
||||
let dictionaryBlock = dictionary.promptFragment()
|
||||
let contextGuideline = context.appContext.polishGuideline
|
||||
let intensityGuideline = context.intensity.promptGuideline
|
||||
let contract = Self.globalOutputContract(useChinese: shouldUseChineseGuidance(providerId: providerId))
|
||||
let precedingBlock = context.precedingForPrompt
|
||||
.map { "上文(仅供参考,**不要**改写):\n\($0)\n" } ?? ""
|
||||
let useChinese = shouldUseChineseGuidance(providerId: store.providerId)
|
||||
.map {
|
||||
"""
|
||||
## 上文(仅供参考 — 用于术语/语气/是否续接列表或换行;**禁止**改写上文,**禁止**从上文新增事实)
|
||||
\($0)
|
||||
|
||||
"""
|
||||
} ?? ""
|
||||
let useChinese = shouldUseChineseGuidance(providerId: providerId)
|
||||
|
||||
if useChinese {
|
||||
return """
|
||||
你是智能语音输入法的后处理引擎。一次完成三件事:
|
||||
你是智能语音输入法的后处理引擎。一次完成:ASR 纠错、标点恢复、语义分段、按档位润色。
|
||||
|
||||
\(contract)
|
||||
|
||||
## 任务 1:纠错
|
||||
- 修正明显的语音识别错误(同音字、近音字、漏字、错字)
|
||||
- 修正专有名词、英文术语(参考下面的用户词典)
|
||||
- **绝不**修改数字、人名、地名(除非明显错得离谱)
|
||||
|
||||
## 任务 2:润色
|
||||
- 删除冗余的语气词(嗯、呃、那个、就是、然后、对、ok)
|
||||
- 删除重复说错的字句
|
||||
- 必要时调整语序让表达更通顺
|
||||
- 加合适的标点
|
||||
## 任务 2:标点与结构
|
||||
- 恢复合理标点与句子边界
|
||||
- 识别口语中的列表、步骤、分点、会议纪要结构并格式化
|
||||
- 长文本按语义换行分段
|
||||
|
||||
## 任务 3:风格适配
|
||||
## 任务 3:润色(按档位)
|
||||
当前输入场景:\(context.appContext.rawValue)
|
||||
风格要求:\(contextGuideline)
|
||||
润色档位:\(intensityGuideline)
|
||||
|
||||
## 重要规则
|
||||
1. **最小改动原则**:原文已经能听懂的部分不要重写
|
||||
2. 保留说话人的口吻和意图
|
||||
3. 不添加原文中没有的信息
|
||||
4. 短句(≤ 8 个中文字符 或 ≤ 15 个英文字符)直接原样返回,不要润色
|
||||
5. 输出语言必须与原文一致
|
||||
|
||||
\(dictionaryBlock.isEmpty ? "" : "## 用户词典(必须原样保留,禁止改写)\n\(dictionaryBlock)\n")
|
||||
\(precedingBlock)
|
||||
## 原文
|
||||
\(precedingBlock)## 原文
|
||||
\(text)
|
||||
|
||||
请直接输出处理后的文本,**不要任何解释**。
|
||||
"""
|
||||
} else {
|
||||
return """
|
||||
You are the post-processing engine of a voice-input keyboard. Complete three tasks in one pass:
|
||||
You are the post-processing engine of a voice-input keyboard. In one pass: fix ASR errors, restore punctuation, structure content, and polish per intensity.
|
||||
|
||||
\(contract)
|
||||
|
||||
## Task 1: Correction
|
||||
- Fix obvious speech-recognition errors (homophones, near-misses, missing/extra characters).
|
||||
- Correct proper nouns, English terms, and technical identifiers (see the user dictionary below).
|
||||
- **Never** alter numbers, person names, or place names unless clearly wrong.
|
||||
|
||||
## Task 2: Polish
|
||||
- Remove redundant filler words (um, uh, like, you know, basically).
|
||||
- Remove duplicated fragments the speaker self-corrected.
|
||||
- Adjust obviously broken word order.
|
||||
- Add appropriate punctuation and capitalization.
|
||||
## Task 2: Punctuation and structure
|
||||
- Restore proper punctuation and sentence boundaries.
|
||||
- Detect oral lists, steps, enumerated points, meeting-note structure and format them.
|
||||
- Break long speech into semantic paragraphs.
|
||||
|
||||
## Task 3: Style adaptation
|
||||
## Task 3: Polish (per intensity)
|
||||
Current input context: \(context.appContext.rawValue)
|
||||
Style guideline: \(contextGuideline)
|
||||
Polish intensity: \(intensityGuideline)
|
||||
|
||||
## Hard rules
|
||||
1. Minimum-change principle: do not rewrite parts the user already said clearly.
|
||||
2. Preserve the speaker's voice and intent.
|
||||
3. Never add information that is not in the original.
|
||||
4. Short inputs (≤ 15 English words or ≤ 8 CJK characters) must be returned verbatim.
|
||||
5. Output language must match the input language.
|
||||
|
||||
\(dictionaryBlock.isEmpty ? "" : "## User dictionary (must be preserved verbatim)\n\(dictionaryBlock)\n")
|
||||
\(precedingBlock)
|
||||
## Original transcript
|
||||
\(precedingBlock)## Original transcript
|
||||
\(text)
|
||||
|
||||
Output the processed text directly. **No explanation, no quotes, no preamble.**
|
||||
@@ -312,7 +321,6 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Chinese-native LLM providers get a Chinese prompt, English ones get English.
|
||||
private func shouldUseChineseGuidance(providerId: String) -> Bool {
|
||||
switch providerId {
|
||||
case "zhipu", "moonshot", "qwen", "deepseek":
|
||||
@@ -322,19 +330,35 @@ public actor PolishingService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale polish budget with transcript length (3-minute Flow utterances).
|
||||
private func effectiveTimeout(for text: String) -> TimeInterval {
|
||||
let scaled = timeout + (Double(text.count) / 200.0) * 2.0
|
||||
/// Per-request HTTP timeout, scaled with transcript length. This is
|
||||
/// the *actual* value handed to `LLMClient.polish(timeout:)`, so long
|
||||
/// dictations (which generate long, listified, multi-paragraph output)
|
||||
/// are not cut off mid-generation by a fixed 15 s ceiling. Grows by
|
||||
/// ~10 s per 100 characters, capped at 120 s.
|
||||
///
|
||||
/// Previously this value was computed but only used for the safety-net
|
||||
/// timer while the URLRequest stayed pinned at 15 s — the scaling was
|
||||
/// dead code and long transcripts timed out, falling back to the raw
|
||||
/// (unpolished, unsegmented) ASR text.
|
||||
internal func effectiveTimeout(for text: String) -> TimeInterval {
|
||||
let scaled = timeout + (Double(text.count) / 100.0) * 10.0
|
||||
return min(max(scaled, timeout), 120)
|
||||
}
|
||||
|
||||
/// Picks base URL + model for one remote polish call.
|
||||
///
|
||||
/// When `providerIdOverride` is set (local engine pins DeepSeek),
|
||||
/// always use that preset's defaults so cloud-engine settings
|
||||
/// (e.g. Qwen base URL saved while testing cloud mode) are not
|
||||
/// mixed with the pinned provider's API key. Cloud engine passes
|
||||
/// `nil` and keeps honoring `store.baseURL` / `store.model`.
|
||||
internal static func resolvedProviderId(
|
||||
store: AppGroupStore,
|
||||
providerIdOverride: String?
|
||||
) -> String {
|
||||
if let providerIdOverride {
|
||||
return providerIdOverride
|
||||
}
|
||||
if store.engineMode == "local" {
|
||||
return "deepseek"
|
||||
}
|
||||
let id = store.providerId
|
||||
return id == "deepseek" ? "openai" : id
|
||||
}
|
||||
|
||||
internal static func resolveLLMEndpoint(
|
||||
store: AppGroupStore,
|
||||
preset: LLMProvider,
|
||||
@@ -344,10 +368,6 @@ public actor PolishingService {
|
||||
return (preset.defaultBaseURL, preset.defaultModel)
|
||||
}
|
||||
let baseURL = store.baseURL.isEmpty ? preset.defaultBaseURL : store.baseURL
|
||||
// Pre-existing typo fix: the user-overridden `store.model`
|
||||
// path was returning `preset.defaultModel` on both branches,
|
||||
// silently ignoring the user's custom model field. Restore
|
||||
// the asymmetry so the user override actually wins.
|
||||
let model = store.model.isEmpty ? preset.defaultModel : store.model
|
||||
return (baseURL, model)
|
||||
}
|
||||
@@ -361,7 +381,7 @@ extension PolishingService.PolishError: LocalizedError {
|
||||
case .timeout:
|
||||
return "LLM polish timed out."
|
||||
case .missingAPIKey:
|
||||
return "Missing API key (local: set PreconfiguredKeys.deepseek; cloud: Settings API key)."
|
||||
return "Missing API key (cloud: Settings API key; local: build configuration)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// PreconfiguredKeys.local.swift.example
|
||||
// Copy to PreconfiguredKeys.local.swift (gitignored) before building.
|
||||
// `./Scripts/generate-xcodeproj.sh` creates PreconfiguredKeys.local.swift
|
||||
// from this file automatically when it is missing.
|
||||
//
|
||||
// The DeepSeek key is used ONLY by the local engine's built-in polish step.
|
||||
// Do not commit the real key — keep it in PreconfiguredKeys.local.swift on
|
||||
// your machine only.
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PreconfiguredKeysLocal {
|
||||
static let deepseek = "TODO_FILL_LATER_DEEPSEEK_KEY"
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
// PreconfiguredKeys.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// v0.2.1 follow-up: preconfigured API keys for built-in cloud providers
|
||||
// the keyboard ships with out of the box. Today the only one is DeepSeek
|
||||
// — the local engine's default polish vendor (see
|
||||
// `ProviderConfig.localModeProviderId`). Future builds may pre-fill
|
||||
// additional providers as we harden them.
|
||||
// Built-in API keys for engine-specific polish vendors. The local engine
|
||||
// pins DeepSeek; the actual key lives in `PreconfiguredKeys.local.swift`
|
||||
// (gitignored) so it never ships in the public repo.
|
||||
//
|
||||
// These constants live in source so a developer building from the repo
|
||||
// can swap in their own key once and have every Debug / TestFlight build
|
||||
// "just work" without round-tripping the Keychain settings UI.
|
||||
//
|
||||
// IMPORTANT: Replace the placeholder string with a real key before
|
||||
// shipping a build. The DEBUG assert below catches the placeholder at
|
||||
// launch so nobody accidentally publishes an "always 401" build.
|
||||
// `./Scripts/generate-xcodeproj.sh` copies
|
||||
// `PreconfiguredKeys.local.swift.example` → `PreconfiguredKeys.local.swift`
|
||||
// on first run. Replace the placeholder in the local file before
|
||||
// distributing a build that uses the local engine.
|
||||
|
||||
import Foundation
|
||||
|
||||
@@ -22,29 +17,33 @@ public enum PreconfiguredKeys {
|
||||
/// this is treated as "configured".
|
||||
private static let placeholder = "TODO_FILL_LATER_DEEPSEEK_KEY"
|
||||
|
||||
/// Preconfigured DeepSeek API key. Replace `placeholder` with a
|
||||
/// real key in `Sources/.../PreconfiguredKeys.swift` before
|
||||
/// distributing a build.
|
||||
public static let deepseek: String = "REMOVED_LEAKED_DEEPSEEK_KEY"
|
||||
/// DeepSeek API key for the local engine's built-in polish step.
|
||||
public static var deepseek: String {
|
||||
PreconfiguredKeysLocal.deepseek
|
||||
}
|
||||
|
||||
public static var isDeepseekConfigured: Bool {
|
||||
deepseek != placeholder && !deepseek.isEmpty
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
/// Forces a lazy init at app launch in DEBUG builds so the assert
|
||||
/// below fires immediately when somebody forgets to swap the
|
||||
/// placeholder. The boolean is intentionally unused at runtime —
|
||||
/// it's a tripwire.
|
||||
public static let isDeepseekConfigured: Bool = {
|
||||
public static let debugDeepseekTripwire: Bool = {
|
||||
assert(
|
||||
deepseek != placeholder,
|
||||
"DeepSeek preconfigured key not filled — replace TODO_FILL_LATER_DEEPSEEK_KEY in PreconfiguredKeys.swift before building"
|
||||
isDeepseekConfigured,
|
||||
"DeepSeek preconfigured key not filled — copy PreconfiguredKeys.local.swift.example to PreconfiguredKeys.local.swift and set your key"
|
||||
)
|
||||
return deepseek != placeholder
|
||||
return isDeepseekConfigured
|
||||
}()
|
||||
|
||||
/// Touch the tripwire so the assert fires at launch rather than
|
||||
/// only the first time the local engine actually tries to polish.
|
||||
/// Called from app startup; safe to invoke multiple times.
|
||||
public static func assertProductionReadinessAtLaunch() {
|
||||
_ = isDeepseekConfigured
|
||||
_ = debugDeepseekTripwire
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// TranscriptPostProcessor.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Deterministic post-processing after the LLM polish step. The LLM
|
||||
// handles semantic punctuation and structure; this module enforces
|
||||
// hard output constraints (emoji ban, list normalization, quality
|
||||
// gate) and decides when ultra-short inputs can skip the LLM entirely.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum TranscriptPostProcessor: Sendable {
|
||||
|
||||
/// Result of the quality gate applied to LLM output.
|
||||
public enum GateDecision: Equatable, Sendable {
|
||||
case accept(String)
|
||||
case fallback(String)
|
||||
}
|
||||
|
||||
// MARK: - Short-circuit gate (skip LLM)
|
||||
|
||||
/// Returns `true` when the transcript is short enough and lacks
|
||||
/// structural signals so calling the LLM would add latency without
|
||||
/// meaningful benefit (e.g. "好", "OK", "明天见").
|
||||
public static func shouldSkipLLM(for text: String) -> Bool {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
if hasStructureSignal(in: trimmed) { return false }
|
||||
|
||||
let cjkCount = trimmed.unicodeScalars.filter(isCJKScalar).count
|
||||
if cjkCount > 0 {
|
||||
// e.g. 好, 嗯, 收到, 明天见
|
||||
return trimmed.count <= 4 && cjkCount <= 4
|
||||
}
|
||||
|
||||
// e.g. OK, yes, thanks — single short token only
|
||||
let words = trimmed.split(whereSeparator: { $0.isWhitespace })
|
||||
return words.count == 1 && trimmed.count <= 10
|
||||
}
|
||||
|
||||
/// Local-only cleanup when the LLM is skipped. Keeps the speaker's
|
||||
/// words verbatim — no punctuation invention beyond trimming.
|
||||
public static func localClean(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - Post-LLM pipeline
|
||||
|
||||
/// Apply deterministic cleanup and quality gate to LLM output.
|
||||
public static func process(original: String, llmOutput: String) -> String {
|
||||
let trimmedOriginal = original.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let decision = qualityGate(original: trimmedOriginal, candidate: llmOutput)
|
||||
switch decision {
|
||||
case .accept(let text):
|
||||
return text
|
||||
case .fallback(let text):
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
/// Quality gate: clean the LLM output deterministically.
|
||||
///
|
||||
/// Design note: earlier revisions reverted to the *raw ASR*
|
||||
/// transcript when numbers changed or the text grew "too much".
|
||||
/// That was wrong — listifying and correcting ASR mis-hearings
|
||||
/// (e.g. "第2:00" → "第二点") legitimately change the number set,
|
||||
/// so the heuristic threw away good output and re-inserted the raw,
|
||||
/// mis-heard transcript (the worst possible text). We now only fall
|
||||
/// back when the model returned genuinely unusable output (empty, or
|
||||
/// pure explanation), and even then we prefer a cleaned candidate
|
||||
/// over the raw transcript.
|
||||
public static func qualityGate(original: String, candidate: String) -> GateDecision {
|
||||
var text = candidate.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if text.isEmpty {
|
||||
return .fallback(localClean(original))
|
||||
}
|
||||
|
||||
text = stripExplanatoryPrefix(from: text)
|
||||
text = unwrapSurroundingQuotes(text)
|
||||
text = stripAddedEmojis(original: original, output: text)
|
||||
text = repairMidSentenceLineBreaks(text)
|
||||
text = normalizeWhitespaceAndPunctuation(text)
|
||||
text = normalizeNumberedLists(text)
|
||||
|
||||
// If cleanup emptied the candidate (e.g. it was only an
|
||||
// explanatory prefix), fall back to the trimmed original rather
|
||||
// than the raw ASR — that is still the least-bad option here.
|
||||
if text.isEmpty {
|
||||
return .fallback(localClean(original))
|
||||
}
|
||||
|
||||
return .accept(text)
|
||||
}
|
||||
|
||||
// MARK: - Structure detection
|
||||
|
||||
/// Whether the transcript contains oral enumeration / section cues.
|
||||
public static func hasStructureSignal(in text: String) -> Bool {
|
||||
let patterns = [
|
||||
#"第[一二三四五六七八九十\d]+[点个条段步部分]"#,
|
||||
#"步骤[一二三四五六七八九十\d]+"#,
|
||||
#"[一二三四五六七八九十]+是"#,
|
||||
#"首先|其次|再次|最后|另外|再者|一方面|另一方面"#,
|
||||
#"\b(first|second|third|fourth|fifth|finally|next|another)\b"#,
|
||||
#"\b(step\s*(one|two|three|four|five|\d+))\b"#,
|
||||
#"point\s*(one|two|three|four|five|\d+)"#,
|
||||
]
|
||||
for pattern in patterns {
|
||||
if text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MARK: - Emoji
|
||||
|
||||
/// Remove emojis from output when the original had none; otherwise
|
||||
/// keep only emojis that appeared in the original.
|
||||
public static func stripAddedEmojis(original: String, output: String) -> String {
|
||||
let originalEmojis = Set(extractEmojis(from: original))
|
||||
if originalEmojis.isEmpty {
|
||||
return removeAllEmojis(from: output)
|
||||
}
|
||||
return String(output.unicodeScalars.filter { scalar in
|
||||
if isEmojiScalar(scalar) {
|
||||
return originalEmojis.contains(String(scalar))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - List normalization
|
||||
|
||||
/// Matches a line that begins with any list marker we recognize
|
||||
/// (bullet, arabic number, 第X点, 步骤X).
|
||||
static let listLinePattern =
|
||||
#"^\s*(?:[-*•]|\d+[.))、]|第[一二三四五六七八九十\d]+[点.))、]|步骤[一二三四五六七八九十\d]+[.))、]?)\s+"#
|
||||
|
||||
/// Whether a line is a list item.
|
||||
static func isListLine(_ text: String) -> Bool {
|
||||
text.range(of: listLinePattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
/// Normalize heterogeneous numbered-list markers to `1. ` style.
|
||||
public static func normalizeNumberedLists(_ text: String) -> String {
|
||||
var lines = text.components(separatedBy: .newlines)
|
||||
var listIndex = 0
|
||||
var inList = false
|
||||
|
||||
for i in lines.indices {
|
||||
let line = lines[i]
|
||||
guard let range = line.range(of: listLinePattern, options: .regularExpression) else {
|
||||
if !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
||||
inList = false
|
||||
listIndex = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
let content = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)
|
||||
if !inList { listIndex = 0 }
|
||||
listIndex += 1
|
||||
inList = true
|
||||
lines[i] = "\(listIndex). \(content)"
|
||||
}
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Mid-sentence line-break repair
|
||||
|
||||
/// Join line breaks that split a sentence. A newline is kept only
|
||||
/// when it is a paragraph break (blank line), a list boundary, or
|
||||
/// the previous line ends with a sentence terminator. Otherwise the
|
||||
/// break is treated as an ASR chunk-stitch artifact (e.g.
|
||||
/// "包括\n这些问题") and merged back into one line.
|
||||
public static func repairMidSentenceLineBreaks(_ text: String) -> String {
|
||||
let lines = text.components(separatedBy: "\n")
|
||||
guard lines.count > 1 else { return text }
|
||||
|
||||
var out: [String] = []
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
guard let last = out.last else {
|
||||
out.append(line)
|
||||
continue
|
||||
}
|
||||
let prevTrimmed = last.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.isEmpty || prevTrimmed.isEmpty
|
||||
|| isListLine(trimmed) || isListLine(prevTrimmed)
|
||||
|| endsWithSentenceTerminator(prevTrimmed) {
|
||||
out.append(line)
|
||||
continue
|
||||
}
|
||||
|
||||
out[out.count - 1] = prevTrimmed + joinGlue(prev: prevTrimmed, next: trimmed) + trimmed
|
||||
}
|
||||
return out.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Whitespace / punctuation cleanup
|
||||
|
||||
public static func normalizeWhitespaceAndPunctuation(_ text: String) -> String {
|
||||
var result = text
|
||||
// Collapse 3+ newlines to 2.
|
||||
while result.contains("\n\n\n") {
|
||||
result = result.replacingOccurrences(of: "\n\n\n", with: "\n\n")
|
||||
}
|
||||
// Collapse duplicate Chinese / Western punctuation.
|
||||
let dupPairs = [
|
||||
("。。", "。"), (",,", ","), ("??", "?"), ("!!", "!"),
|
||||
("..", "."), (",,", ","), ("??", "?"), ("!!", "!"),
|
||||
]
|
||||
for (dup, single) in dupPairs {
|
||||
while result.contains(dup) {
|
||||
result = result.replacingOccurrences(of: dup, with: single)
|
||||
}
|
||||
}
|
||||
return result.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
// MARK: - Prefix / quote cleanup
|
||||
|
||||
public static func stripExplanatoryPrefix(from text: String) -> String {
|
||||
let prefixes = [
|
||||
"以下是", "处理后", "处理后的文本", "输出如下", "结果如下",
|
||||
"Here is", "Here's", "Output:", "Result:", "Processed text:",
|
||||
]
|
||||
var result = text
|
||||
for prefix in prefixes {
|
||||
if result.hasPrefix(prefix) {
|
||||
result = String(result.dropFirst(prefix.count))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if result.hasPrefix(":") || result.hasPrefix(":") {
|
||||
result = String(result.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
public static func unwrapSurroundingQuotes(_ text: String) -> String {
|
||||
guard text.count >= 2 else { return text }
|
||||
let pairs: [(Character, Character)] = [("\"", "\""), ("'", "'"), ("「", "」"), ("“", "”")]
|
||||
for (open, close) in pairs {
|
||||
if text.first == open, text.last == close {
|
||||
return String(text.dropFirst().dropLast())
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Private helpers
|
||||
|
||||
private static func endsWithSentenceTerminator(_ text: String) -> Bool {
|
||||
guard let last = text.unicodeScalars.last else { return false }
|
||||
let terminators: Set<Unicode.Scalar> = [
|
||||
"。", "!", "?", "…", "!", "?", ".", ";", ";", ":", ":",
|
||||
]
|
||||
return terminators.contains(last)
|
||||
}
|
||||
|
||||
/// Decide the glue between two merged fragments: a space only when
|
||||
/// both sides are ASCII alphanumeric (English words); nothing for CJK.
|
||||
private static func joinGlue(prev: String, next: String) -> String {
|
||||
guard let p = prev.unicodeScalars.last, let n = next.unicodeScalars.first else { return "" }
|
||||
let alphanumerics = CharacterSet.alphanumerics
|
||||
let pAscii = p.isASCII && alphanumerics.contains(p)
|
||||
let nAscii = n.isASCII && alphanumerics.contains(n)
|
||||
return (pAscii && nAscii) ? " " : ""
|
||||
}
|
||||
|
||||
private static func extractEmojis(from text: String) -> [String] {
|
||||
text.unicodeScalars.filter(isEmojiScalar).map { String($0) }
|
||||
}
|
||||
|
||||
private static func removeAllEmojis(from text: String) -> String {
|
||||
String(text.unicodeScalars.filter { !isEmojiScalar($0) })
|
||||
.replacingOccurrences(of: " ", with: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func isEmojiScalar(_ scalar: Unicode.Scalar) -> Bool {
|
||||
scalar.properties.isEmoji && (scalar.value > 0x238C || scalar.properties.isEmojiPresentation)
|
||||
}
|
||||
|
||||
private static func isCJKScalar(_ scalar: Unicode.Scalar) -> Bool {
|
||||
switch scalar.value {
|
||||
case 0x4E00...0x9FFF, 0x3400...0x4DBF, 0xF900...0xFAFF:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
"engine.asr.appleSpeech" = "Apple SpeechAnalyzer";
|
||||
|
||||
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
|
||||
"flow.warning.cloudPolishMissingKey" = "Cloud polish/translation needs a DeepSeek API key. Inserted raw ASR text — set PreconfiguredKeys.deepseek in the project (local engine) or API key in Settings (cloud engine).";
|
||||
"flow.warning.cloudPolishMissingKey" = "Cloud polish needs an API key in Settings. Inserted raw ASR text.";
|
||||
"flow.warning.localPolishUnavailable" = "Built-in polish is unavailable. Inserted raw ASR text.";
|
||||
|
||||
/* LLM providers */
|
||||
"provider.openai" = "OpenAI";
|
||||
@@ -54,10 +55,10 @@
|
||||
"polish.intensity.light" = "Light";
|
||||
"polish.intensity.medium" = "Medium";
|
||||
"polish.intensity.heavy" = "Heavy";
|
||||
"polish.intensity.off.desc" = "Insert the raw ASR transcript with no LLM call.";
|
||||
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications.";
|
||||
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Default.";
|
||||
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs. Best for meeting notes and reports.";
|
||||
"polish.intensity.off.desc" = "No polish; inserts raw ASR unless your personal dictionary has entries, then runs ASR correction only.";
|
||||
"polish.intensity.light.desc" = "Drop only isolated filler words and obvious duplications. Punctuation and structure still apply.";
|
||||
"polish.intensity.medium.desc" = "Correct recognition errors, remove fillers, polish phrasing. Punctuation and structure always apply. Default.";
|
||||
"polish.intensity.heavy.desc" = "Restructure into lists and paragraphs when needed. Best for meeting notes and reports.";
|
||||
|
||||
/* v0.3.0: Detected app context labels */
|
||||
"appContext.code" = "Code";
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"engine.asr.appleSpeech" = "Apple 语音识别";
|
||||
|
||||
/* v0.2.0: flow-level warnings surfaced alongside the final transcript. */
|
||||
"flow.warning.cloudPolishMissingKey" = "云端润色/翻译需要 DeepSeek API Key,本次已插入原始识别结果。本地引擎请在 PreconfiguredKeys.swift 配置;云端引擎请在设置中填写 API Key。";
|
||||
"flow.warning.cloudPolishMissingKey" = "云端润色需要先在设置中填写 API Key,本次已插入原始识别结果。";
|
||||
"flow.warning.localPolishUnavailable" = "内置润色暂不可用,本次已插入原始识别结果。";
|
||||
|
||||
/* LLM providers */
|
||||
"provider.openai" = "OpenAI";
|
||||
@@ -54,9 +55,9 @@
|
||||
"polish.intensity.light" = "轻度";
|
||||
"polish.intensity.medium" = "中度";
|
||||
"polish.intensity.heavy" = "深度";
|
||||
"polish.intensity.off.desc" = "不调用 LLM,直接插入识别原文。";
|
||||
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误。";
|
||||
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句。推荐默认。";
|
||||
"polish.intensity.off.desc" = "不润色;词库为空时直接插入识别原文,有词条时仅做 ASR 纠错。";
|
||||
"polish.intensity.light.desc" = "仅清除孤立语气词和重复口误;标点和结构化仍会生效。";
|
||||
"polish.intensity.medium.desc" = "纠正识别错误、清除语气词、润色语句;始终补标点与结构化。推荐默认。";
|
||||
"polish.intensity.heavy.desc" = "可重组段落、拆长句、自动编号。适合会议纪要与报告。";
|
||||
|
||||
/* v0.3.0: 输入场景标签 */
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.xcconfig(gitignored,不会被覆盖)
|
||||
|
||||
# 项目级签名 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
|
||||
|
||||
Reference in New Issue
Block a user