From 05e005e9ceda4ac1e67cd649c73cfe6d51339bc4 Mon Sep 17 00:00:00 2001 From: Rocky <72559939+hkgood@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:27:23 +0800 Subject: [PATCH] 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. --- .gitignore | 8 + AGENTS.md | 104 +++++ CHANGELOG.md | 33 +- .../Services/DictionaryAliasGenerator.swift | 102 +++++ OSGKeyboard/Services/DictionaryLearner.swift | 268 ------------- OSGKeyboard/Services/FlowSessionManager.swift | 7 +- .../Views/Components/HomeStatsCard.swift | 53 +-- .../Views/Components/MinimalTabBar.swift | 27 +- .../Components/PageHeaderConfirmButton.swift | 91 ----- .../Views/Components/PageHeaderRow.swift | 37 -- .../Views/Components/TabBarVisibility.swift | 56 +++ OSGKeyboard/Views/EnginePickerSection.swift | 4 +- OSGKeyboard/Views/HelpFeedbackView.swift | 1 + OSGKeyboard/Views/HistoryView.swift | 83 ++-- OSGKeyboard/Views/HomeView.swift | 93 ++--- .../Views/LocalEngineSettingsRows.swift | 64 +-- OSGKeyboard/Views/MainTabView.swift | 21 +- OSGKeyboard/Views/OnboardingView.swift | 23 +- .../Views/OpenSourceLicensesView.swift | 6 +- .../Views/PersonalDictionaryEntrySheet.swift | 85 ++++ .../Views/PersonalDictionaryView.swift | 252 +++++++----- OSGKeyboard/Views/PrivacyPolicyView.swift | 1 + OSGKeyboard/Views/ProviderPickerSection.swift | 6 +- OSGKeyboard/Views/SettingsView.swift | 270 +++++++------ OSGKeyboard/en.lproj/Localizable.strings | 19 +- OSGKeyboard/zh-Hans.lproj/Localizable.strings | 21 +- OSGKeyboardExt/KeyboardViewController.swift | 223 ++++++++++- .../Services/AppGroupPersistor.swift | 10 + .../Utilities/KeyboardSoundFeedback.swift | 41 ++ OSGKeyboardExt/Views/CursorDragPad.swift | 263 +++++++++++++ OSGKeyboardExt/Views/KeyboardRootView.swift | 133 +++++-- OSGKeyboardExt/Views/RecordButton.swift | 12 +- .../Views/ToolbarActionButtons.swift | 86 ++-- OSGKeyboardExt/Views/TranslationChip.swift | 37 +- OSGKeyboardExt/en.lproj/Keyboard.strings | 6 +- OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 6 +- OSGKeyboardShared/DesignSystem/Theme.swift | 2 +- OSGKeyboardShared/Models/AppContext.swift | 2 +- OSGKeyboardShared/Models/LLMProvider.swift | 14 +- .../Models/PersonalDictionary.swift | 114 +++++- .../Models/PolishIntensity.swift | 43 +- OSGKeyboardShared/Models/ProviderConfig.swift | 125 +++--- .../Services/AppGroupStore.swift | 70 ++-- .../Services/CursorNavigation.swift | 328 ++++++++++++++++ .../Services/KeyboardState.swift | 33 +- OSGKeyboardShared/Services/Keychain.swift | 69 +++- OSGKeyboardShared/Services/LLMClient.swift | 26 +- .../Services/PolishingService.swift | 282 +++++++------- .../PreconfiguredKeys.local.swift.example | 14 + .../Services/PreconfiguredKeys.swift | 41 +- .../Services/TranscriptPostProcessor.swift | 296 ++++++++++++++ OSGKeyboardShared/en.lproj/Shared.strings | 11 +- .../zh-Hans.lproj/Shared.strings | 9 +- OSGKeyboardTests/CursorNavigationTests.swift | 139 +++++++ OSGKeyboardTests/IntelligentPolishTests.swift | 367 +++++++++++++----- .../KeyboardOnboardingOverlayTests.swift | 10 +- OSGKeyboardTests/KeychainTests.swift | 11 + OSGKeyboardTests/LLMClientTests.swift | 58 +-- Scripts/generate-xcodeproj.sh | 14 + project.yml | 5 +- 60 files changed, 3247 insertions(+), 1388 deletions(-) create mode 100644 OSGKeyboard/Services/DictionaryAliasGenerator.swift delete mode 100644 OSGKeyboard/Services/DictionaryLearner.swift delete mode 100644 OSGKeyboard/Views/Components/PageHeaderConfirmButton.swift delete mode 100644 OSGKeyboard/Views/Components/PageHeaderRow.swift create mode 100644 OSGKeyboard/Views/Components/TabBarVisibility.swift create mode 100644 OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift create mode 100644 OSGKeyboardExt/Utilities/KeyboardSoundFeedback.swift create mode 100644 OSGKeyboardExt/Views/CursorDragPad.swift create mode 100644 OSGKeyboardShared/Services/CursorNavigation.swift create mode 100644 OSGKeyboardShared/Services/PreconfiguredKeys.local.swift.example create mode 100644 OSGKeyboardShared/Services/TranscriptPostProcessor.swift create mode 100644 OSGKeyboardTests/CursorNavigationTests.swift diff --git a/.gitignore b/.gitignore index 4505446..004f648 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ea05d58..d7ee915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 + +``` +(): + +[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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ad954f6..6cafb73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/OSGKeyboard/Services/DictionaryAliasGenerator.swift b/OSGKeyboard/Services/DictionaryAliasGenerator.swift new file mode 100644 index 0000000..f500d98 --- /dev/null +++ b/OSGKeyboard/Services/DictionaryAliasGenerator.swift @@ -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() + 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]) + } +} diff --git a/OSGKeyboard/Services/DictionaryLearner.swift b/OSGKeyboard/Services/DictionaryLearner.swift deleted file mode 100644 index e11f84f..0000000 --- a/OSGKeyboard/Services/DictionaryLearner.swift +++ /dev/null @@ -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 - - init( - minOccurrences: Int = DictionaryLearner.defaultMinOccurrences, - maxHistoryEntries: Int = DictionaryLearner.defaultMaxHistoryEntries, - stopwords: Set = 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 = [ - // 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", - ] -} diff --git a/OSGKeyboard/Services/FlowSessionManager.swift b/OSGKeyboard/Services/FlowSessionManager.swift index 450d42b..5b8282d 100644 --- a/OSGKeyboard/Services/FlowSessionManager.swift +++ b/OSGKeyboard/Services/FlowSessionManager.swift @@ -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") } diff --git a/OSGKeyboard/Views/Components/HomeStatsCard.swift b/OSGKeyboard/Views/Components/HomeStatsCard.swift index b1be20c..7a47b7c 100644 --- a/OSGKeyboard/Views/Components/HomeStatsCard.swift +++ b/OSGKeyboard/Views/Components/HomeStatsCard.swift @@ -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 diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift index 2be669c..d013ff3 100644 --- a/OSGKeyboard/Views/Components/MinimalTabBar.swift +++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift @@ -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) } diff --git a/OSGKeyboard/Views/Components/PageHeaderConfirmButton.swift b/OSGKeyboard/Views/Components/PageHeaderConfirmButton.swift deleted file mode 100644 index 18e06b7..0000000 --- a/OSGKeyboard/Views/Components/PageHeaderConfirmButton.swift +++ /dev/null @@ -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) - } -} diff --git a/OSGKeyboard/Views/Components/PageHeaderRow.swift b/OSGKeyboard/Views/Components/PageHeaderRow.swift deleted file mode 100644 index 717fe7a..0000000 --- a/OSGKeyboard/Views/Components/PageHeaderRow.swift +++ /dev/null @@ -1,37 +0,0 @@ -// PageHeaderRow.swift -// OSGKeyboard · Main App -// -// 左对齐页面标题 + 同行右侧操作区。不用 navigation toolbar 放标题, -// 避免 iOS 把 leading/trailing 项挤进「…」溢出菜单。 - -import SwiftUI -import OSGKeyboardShared - -struct PageHeaderRow: 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() } - } -} diff --git a/OSGKeyboard/Views/Components/TabBarVisibility.swift b/OSGKeyboard/Views/Components/TabBarVisibility.swift new file mode 100644 index 0000000..de3aa4b --- /dev/null +++ b/OSGKeyboard/Views/Components/TabBarVisibility.swift @@ -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) + } +} diff --git a/OSGKeyboard/Views/EnginePickerSection.swift b/OSGKeyboard/Views/EnginePickerSection.swift index 5f75525..81fbf9d 100644 --- a/OSGKeyboard/Views/EnginePickerSection.swift +++ b/OSGKeyboard/Views/EnginePickerSection.swift @@ -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")) + } } } } diff --git a/OSGKeyboard/Views/HelpFeedbackView.swift b/OSGKeyboard/Views/HelpFeedbackView.swift index d2106ba..85e25b0 100644 --- a/OSGKeyboard/Views/HelpFeedbackView.swift +++ b/OSGKeyboard/Views/HelpFeedbackView.swift @@ -31,5 +31,6 @@ struct HelpFeedbackView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.link.support") .navigationBarTitleDisplayMode(.inline) + .hidesTabBarWhenPushed() } } diff --git a/OSGKeyboard/Views/HistoryView.swift b/OSGKeyboard/Views/HistoryView.swift index 74e0c47..7ed584e 100644 --- a/OSGKeyboard/Views/HistoryView.swift +++ b/OSGKeyboard/Views/HistoryView.swift @@ -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") + } } } diff --git a/OSGKeyboard/Views/HomeView.swift b/OSGKeyboard/Views/HomeView.swift index b0a2a17..2a05014 100644 --- a/OSGKeyboard/Views/HomeView.swift +++ b/OSGKeyboard/Views/HomeView.swift @@ -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) } } diff --git a/OSGKeyboard/Views/LocalEngineSettingsRows.swift b/OSGKeyboard/Views/LocalEngineSettingsRows.swift index 946b808..2681243 100644 --- a/OSGKeyboard/Views/LocalEngineSettingsRows.swift +++ b/OSGKeyboard/Views/LocalEngineSettingsRows.swift @@ -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) } -} \ No newline at end of file +} diff --git a/OSGKeyboard/Views/MainTabView.swift b/OSGKeyboard/Views/MainTabView.swift index ec00de5..d4abdb8 100644 --- a/OSGKeyboard/Views/MainTabView.swift +++ b/OSGKeyboard/Views/MainTabView.swift @@ -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() } } } diff --git a/OSGKeyboard/Views/OnboardingView.swift b/OSGKeyboard/Views/OnboardingView.swift index 8aa3e12..58dcd1e 100644 --- a/OSGKeyboard/Views/OnboardingView.swift +++ b/OSGKeyboard/Views/OnboardingView.swift @@ -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) } } diff --git a/OSGKeyboard/Views/OpenSourceLicensesView.swift b/OSGKeyboard/Views/OpenSourceLicensesView.swift index 2b1939c..21e7f6c 100644 --- a/OSGKeyboard/Views/OpenSourceLicensesView.swift +++ b/OSGKeyboard/Views/OpenSourceLicensesView.swift @@ -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() } } diff --git a/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift b/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift new file mode 100644 index 0000000..94d0ef9 --- /dev/null +++ b/OSGKeyboard/Views/PersonalDictionaryEntrySheet.swift @@ -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() + } +} diff --git a/OSGKeyboard/Views/PersonalDictionaryView.swift b/OSGKeyboard/Views/PersonalDictionaryView.swift index 0061453..543269a 100644 --- a/OSGKeyboard/Views/PersonalDictionaryView.swift +++ b/OSGKeyboard/Views/PersonalDictionaryView.swift @@ -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 = [] 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 diff --git a/OSGKeyboard/Views/PrivacyPolicyView.swift b/OSGKeyboard/Views/PrivacyPolicyView.swift index 6ee1d71..00db8f9 100644 --- a/OSGKeyboard/Views/PrivacyPolicyView.swift +++ b/OSGKeyboard/Views/PrivacyPolicyView.swift @@ -18,6 +18,7 @@ struct PrivacyPolicyView: View { .background(palette.background.ignoresSafeArea()) .navigationTitle("settings.privacy.policy") .navigationBarTitleDisplayMode(.inline) + .hidesTabBarWhenPushed() } private var privacyScrollAnchor: String? { diff --git a/OSGKeyboard/Views/ProviderPickerSection.swift b/OSGKeyboard/Views/ProviderPickerSection.swift index fbbff9a..7ed1e03 100644 --- a/OSGKeyboard/Views/ProviderPickerSection.swift +++ b/OSGKeyboard/Views/ProviderPickerSection.swift @@ -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 { diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index f5ca47d..6c7cca4 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -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 { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 06299a4..ccc3572 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -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."; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index 40058f1..e3305ef 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -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" = "这会移除所有受保护的词汇,且无法撤销。"; diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift index f67d6c9..153ca37 100644 --- a/OSGKeyboardExt/KeyboardViewController.swift +++ b/OSGKeyboardExt/KeyboardViewController.swift @@ -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 { + 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! + private var hosting: UIHostingController? + /// 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? 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)") } } diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift index 1b0f943..802e1e0 100644 --- a/OSGKeyboardExt/Services/AppGroupPersistor.swift +++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift @@ -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. diff --git a/OSGKeyboardExt/Utilities/KeyboardSoundFeedback.swift b/OSGKeyboardExt/Utilities/KeyboardSoundFeedback.swift new file mode 100644 index 0000000..3306221 --- /dev/null +++ b/OSGKeyboardExt/Utilities/KeyboardSoundFeedback.swift @@ -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) + } + } +} diff --git a/OSGKeyboardExt/Views/CursorDragPad.swift b/OSGKeyboardExt/Views/CursorDragPad.swift new file mode 100644 index 0000000..d97cf9e --- /dev/null +++ b/OSGKeyboardExt/Views/CursorDragPad.swift @@ -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, with event: UIEvent?) { + super.touchesBegan(touches, with: event) + guard isPadEnabled else { return } + backgroundColor = Self.activeTint + coordinator?.onPressingChanged(true) + } + + override func touchesEnded(_ touches: Set, with event: UIEvent?) { + super.touchesEnded(touches, with: event) + applyIdleTint() + coordinator?.onPressingChanged(false) + } + + override func touchesCancelled(_ touches: Set, 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 + } +} diff --git a/OSGKeyboardExt/Views/KeyboardRootView.swift b/OSGKeyboardExt/Views/KeyboardRootView.swift index 034f126..d1319eb 100644 --- a/OSGKeyboardExt/Views/KeyboardRootView.swift +++ b/OSGKeyboardExt/Views/KeyboardRootView.swift @@ -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 { diff --git a/OSGKeyboardExt/Views/RecordButton.swift b/OSGKeyboardExt/Views/RecordButton.swift index 379b524..7caa159 100644 --- a/OSGKeyboardExt/Views/RecordButton.swift +++ b/OSGKeyboardExt/Views/RecordButton.swift @@ -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) } diff --git a/OSGKeyboardExt/Views/ToolbarActionButtons.swift b/OSGKeyboardExt/Views/ToolbarActionButtons.swift index 16bca44..1a69249 100644 --- a/OSGKeyboardExt/Views/ToolbarActionButtons.swift +++ b/OSGKeyboardExt/Views/ToolbarActionButtons.swift @@ -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: 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 + } } } diff --git a/OSGKeyboardExt/Views/TranslationChip.swift b/OSGKeyboardExt/Views/TranslationChip.swift index 5dcc082..ba97f1c 100644 --- a/OSGKeyboardExt/Views/TranslationChip.swift +++ b/OSGKeyboardExt/Views/TranslationChip.swift @@ -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") diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings index 29e2858..704e997 100644 --- a/OSGKeyboardExt/en.lproj/Keyboard.strings +++ b/OSGKeyboardExt/en.lproj/Keyboard.strings @@ -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"; diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings index a7fbe69..22c69bb 100644 --- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings +++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings @@ -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" = "通用 — 中性口吻"; diff --git a/OSGKeyboardShared/DesignSystem/Theme.swift b/OSGKeyboardShared/DesignSystem/Theme.swift index bf19881..dd656a7 100644 --- a/OSGKeyboardShared/DesignSystem/Theme.swift +++ b/OSGKeyboardShared/DesignSystem/Theme.swift @@ -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 diff --git a/OSGKeyboardShared/Models/AppContext.swift b/OSGKeyboardShared/Models/AppContext.swift index 7ef3251..c40014f 100644 --- a/OSGKeyboardShared/Models/AppContext.swift +++ b/OSGKeyboardShared/Models/AppContext.swift @@ -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: diff --git a/OSGKeyboardShared/Models/LLMProvider.swift b/OSGKeyboardShared/Models/LLMProvider.swift index 7e6fcdd..1ec3898 100644 --- a/OSGKeyboardShared/Models/LLMProvider.swift +++ b/OSGKeyboardShared/Models/LLMProvider.swift @@ -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) + } } diff --git a/OSGKeyboardShared/Models/PersonalDictionary.swift b/OSGKeyboardShared/Models/PersonalDictionary.swift index af2860e..7952479 100644 --- a/OSGKeyboardShared/Models/PersonalDictionary.swift +++ b/OSGKeyboardShared/Models/PersonalDictionary.swift @@ -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] = [] diff --git a/OSGKeyboardShared/Models/PolishIntensity.swift b/OSGKeyboardShared/Models/PolishIntensity.swift index d74531e..76d6b90 100644 --- a/OSGKeyboardShared/Models/PolishIntensity.swift +++ b/OSGKeyboardShared/Models/PolishIntensity.swift @@ -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 { diff --git a/OSGKeyboardShared/Models/ProviderConfig.swift b/OSGKeyboardShared/Models/ProviderConfig.swift index 8b56894..fb24a21 100644 --- a/OSGKeyboardShared/Models/ProviderConfig.swift +++ b/OSGKeyboardShared/Models/ProviderConfig.swift @@ -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 } diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift index c2662cc..fb9faae 100644 --- a/OSGKeyboardShared/Services/AppGroupStore.swift +++ b/OSGKeyboardShared/Services/AppGroupStore.swift @@ -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)") diff --git a/OSGKeyboardShared/Services/CursorNavigation.swift b/OSGKeyboardShared/Services/CursorNavigation.swift new file mode 100644 index 0000000..8e1e434 --- /dev/null +++ b/OSGKeyboardShared/Services/CursorNavigation.swift @@ -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 diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift index 167db61..9510211 100644 --- a/OSGKeyboardShared/Services/KeyboardState.swift +++ b/OSGKeyboardShared/Services/KeyboardState.swift @@ -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) diff --git a/OSGKeyboardShared/Services/Keychain.swift b/OSGKeyboardShared/Services/Keychain.swift index 5983519..73b7967 100644 --- a/OSGKeyboardShared/Services/Keychain.swift +++ b/OSGKeyboardShared/Services/Keychain.swift @@ -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) + } + } } diff --git a/OSGKeyboardShared/Services/LLMClient.swift b/OSGKeyboardShared/Services/LLMClient.swift index f023b63..fc78309 100644 --- a/OSGKeyboardShared/Services/LLMClient.swift +++ b/OSGKeyboardShared/Services/LLMClient.swift @@ -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) diff --git a/OSGKeyboardShared/Services/PolishingService.swift b/OSGKeyboardShared/Services/PolishingService.swift index 69302f0..8ec1bca 100644 --- a/OSGKeyboardShared/Services/PolishingService.swift +++ b/OSGKeyboardShared/Services/PolishingService.swift @@ -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)." } } } diff --git a/OSGKeyboardShared/Services/PreconfiguredKeys.local.swift.example b/OSGKeyboardShared/Services/PreconfiguredKeys.local.swift.example new file mode 100644 index 0000000..5e004ca --- /dev/null +++ b/OSGKeyboardShared/Services/PreconfiguredKeys.local.swift.example @@ -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" +} diff --git a/OSGKeyboardShared/Services/PreconfiguredKeys.swift b/OSGKeyboardShared/Services/PreconfiguredKeys.swift index f6a6e53..3c75f5b 100644 --- a/OSGKeyboardShared/Services/PreconfiguredKeys.swift +++ b/OSGKeyboardShared/Services/PreconfiguredKeys.swift @@ -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 } diff --git a/OSGKeyboardShared/Services/TranscriptPostProcessor.swift b/OSGKeyboardShared/Services/TranscriptPostProcessor.swift new file mode 100644 index 0000000..0831826 --- /dev/null +++ b/OSGKeyboardShared/Services/TranscriptPostProcessor.swift @@ -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 = [ + "。", "!", "?", "…", "!", "?", ".", ";", ";", ":", ":", + ] + 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 + } + } +} diff --git a/OSGKeyboardShared/en.lproj/Shared.strings b/OSGKeyboardShared/en.lproj/Shared.strings index 221dab5..bf5a6f6 100644 --- a/OSGKeyboardShared/en.lproj/Shared.strings +++ b/OSGKeyboardShared/en.lproj/Shared.strings @@ -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"; diff --git a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings index a16254b..6237f1e 100644 --- a/OSGKeyboardShared/zh-Hans.lproj/Shared.strings +++ b/OSGKeyboardShared/zh-Hans.lproj/Shared.strings @@ -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: 输入场景标签 */ diff --git a/OSGKeyboardTests/CursorNavigationTests.swift b/OSGKeyboardTests/CursorNavigationTests.swift new file mode 100644 index 0000000..a2d65f8 --- /dev/null +++ b/OSGKeyboardTests/CursorNavigationTests.swift @@ -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) + ) + ) + } +} diff --git a/OSGKeyboardTests/IntelligentPolishTests.swift b/OSGKeyboardTests/IntelligentPolishTests.swift index 7731808..e46327d 100644 --- a/OSGKeyboardTests/IntelligentPolishTests.swift +++ b/OSGKeyboardTests/IntelligentPolishTests.swift @@ -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 + } +} diff --git a/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift b/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift index 64302fe..4fd3f73 100644 --- a/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift +++ b/OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift @@ -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) } } \ No newline at end of file diff --git a/OSGKeyboardTests/KeychainTests.swift b/OSGKeyboardTests/KeychainTests.swift index a53bea8..d23caf9 100644 --- a/OSGKeyboardTests/KeychainTests.swift +++ b/OSGKeyboardTests/KeychainTests.swift @@ -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. diff --git a/OSGKeyboardTests/LLMClientTests.swift b/OSGKeyboardTests/LLMClientTests.swift index ef63f0c..d4df197 100644 --- a/OSGKeyboardTests/LLMClientTests.swift +++ b/OSGKeyboardTests/LLMClientTests.swift @@ -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) } diff --git a/Scripts/generate-xcodeproj.sh b/Scripts/generate-xcodeproj.sh index d22b132..3667978 100755 --- a/Scripts/generate-xcodeproj.sh +++ b/Scripts/generate-xcodeproj.sh @@ -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' diff --git a/project.yml b/project.yml index 59e0336..8fb8ac0 100644 --- a/project.yml +++ b/project.yml @@ -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