From fb97fa918c752b362406a8d8ab80a009b7f28a7b Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 30 Sep 2026 01:56:37 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(vocab):=20=E5=A2=9E=E5=8A=A0=20Windows?= =?UTF-8?q?=20=E5=92=8C=20Android=20=E7=A1=AE=E8=AE=A4=E5=BC=8F=E6=89=8B?= =?UTF-8?q?=E6=94=B9=E5=AD=A6=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- README.zh.md | 6 +- .../OpenLessAccessibilityCommandReceiver.kt | 5 + .../kotlin/OpenLessAccessibilityService.kt | 226 ++++++++++++- .../app/android/kotlin/OpenLessImeService.kt | 8 +- .../app/android/kotlin/OpenLessNative.kt | 7 + .../kotlin/OpenLessVocabularyReceiver.kt | 73 +++++ .../app/crates/openless-core/src/api.rs | 118 ++++++- .../src/cloud_sync_e2ee_documents/registry.rs | 7 + .../src/cloud_sync_e2ee_documents/tests.rs | 3 +- .../openless-core/src/dictation_context.rs | 2 +- .../openless-core/src/host_document/mod.rs | 2 + .../src/host_document/observation.rs | 158 +++++++++ .../crates/openless-core/src/shared_types.rs | 10 + .../app/scripts/copy-android-scaffolding.mjs | 11 +- .../merge-android-overlay-manifest.mjs | 3 + .../shared-backend-wire-contract.test.mjs | 30 ++ openless-all/app/src-tauri/Cargo.lock | 1 + openless-all/app/src-tauri/Cargo.toml | 4 + .../src-tauri/src/android/edit_observation.rs | 194 +++++++++++ openless-all/app/src-tauri/src/android/jni.rs | 8 +- openless-all/app/src-tauri/src/android/mod.rs | 2 + .../src-tauri/src/android/native_bridge.rs | 56 ++++ .../app/src-tauri/src/commands/dictionary.rs | 23 +- .../app/src-tauri/src/core_adapters.rs | 80 +++-- .../app/src-tauri/src/host_document/mod.rs | 32 +- .../src-tauri/src/host_document/windows.rs | 309 ++++++++++++++++++ openless-all/app/src-tauri/src/lib.rs | 2 + .../app/src-tauri/src/tauri_events.rs | 7 +- .../app/src/components/LearnVocabulary.tsx | 87 +++++ .../src/components/VocabSuggestionCard.tsx | 28 +- openless-all/app/src/i18n/de.ts | 19 +- openless-all/app/src/i18n/en.ts | 19 +- openless-all/app/src/i18n/es.ts | 19 +- openless-all/app/src/i18n/fr.ts | 19 +- openless-all/app/src/i18n/ja.ts | 19 +- openless-all/app/src/i18n/ko.ts | 19 +- openless-all/app/src/i18n/zh-CN.ts | 16 +- openless-all/app/src/i18n/zh-TW.ts | 16 +- openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/ipc/vocab.ts | 15 + openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/pages/History.tsx | 56 ++-- .../src/pages/settings/DataStorageSection.tsx | 12 + 44 files changed, 1597 insertions(+), 143 deletions(-) create mode 100644 openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt create mode 100644 openless-all/app/crates/openless-core/src/host_document/observation.rs create mode 100644 openless-all/app/src-tauri/src/android/edit_observation.rs create mode 100644 openless-all/app/src-tauri/src/host_document/windows.rs create mode 100644 openless-all/app/src/components/LearnVocabulary.tsx diff --git a/README.md b/README.md index 4619efc37..0e4083429 100644 --- a/README.md +++ b/README.md @@ -373,16 +373,16 @@ The dictionary handles your proper nouns, product names, names of people, and ne - Manually adding the correct spelling, a category, and notes. You do not need to maintain misspellings or context hints. - Enabled entries are sent to the ASR provider that supports hotwords (Volcengine `context.hotwords`, StepFun `hotwords`, Whisper-compatible `prompt` — except ZenMux, whose JSON protocol does not carry `prompt`/`hotwords`, Bailian vocabulary ID) so they are recognized correctly during transcription. iFlytek realtime ASR has no request-level hotword parameter — configure personalized hotwords in the iFlytek console instead. - Entries are also injected into the polish prompt: the model decides per sentence whether to substitute. If "Cloud" clearly refers to the AI product `Claude` in context, it is corrected; if it genuinely means cloud computing, it is left as is. -- **The dictionary learns from you.** When you hand-correct a word OpenLess just typed, a card appears asking whether to remember it. One ✓ and it is in — no settings page, no forms. Every suggestion is reviewed by you: nothing is ever added silently. Requires the opt-in **cursor context** setting below, and is macOS-only for now. +- **Learn from corrections (experimental).** Enable this separately in Settings → Privacy → Data storage; it defaults to off and is not enabled by cursor context or cloud sync. On macOS, Windows and Android, supported editors can be observed for up to 60 seconds after insertion. Each suggestion needs confirmation within about 10 seconds before entering the dictionary. Android requires accessibility. When observation is unavailable, use **Remember a word** in history details; Android IME result editing also offers an unchecked dictionary option. Every path requires explicit confirmation and does not create global replacement rules. - **Entries that earn their keep get priority.** The hotword budget sent to ASR providers is finite (a few hundred characters). Entries are ranked by hit count, with a few reserved seats for words you just added by hand, so the terms you actually use keep their place instead of being pushed out by whatever you added most recently. ### Cursor context (opt-in, macOS) Settings → Privacy → Data storage → **Cursor context**. Off by default. -When on, each dictation reads a few hundred characters around your cursor **in the app you are writing in** and sends them with the polish request, so the model knows what you are writing about. Chinese homophones (接口/借口, 大鱼/大禹) are indistinguishable to an acoustic model but obvious from context. This is also what makes dictionary learning possible: OpenLess can only notice that you fixed a word if it can see the text it just typed. +When on, each dictation reads a few hundred characters around your cursor **in the app you are writing in** and sends them with the polish request, so the model knows what you are writing about. Chinese homophones (接口/借口, 大鱼/大禹) are indistinguishable to an acoustic model but obvious from context. Local vocabulary learning has a separate switch and does not require cursor context. Observed text is not sent to a model; words explicitly added to the dictionary participate in future ASR and polish requests as described above. -What it never reads: password fields, macOS Secure Input, password managers, and terminals — those are blocked before a single accessibility call is made. While the setting is off, no accessibility calls happen at all and the prompt is byte-for-byte identical to a build without the feature. +Cursor context excludes password fields, macOS Secure Input, known password managers and terminals. Turning it off stops reading cursor context for polishing; other authorized accessibility features, including local vocabulary learning and insertion, work independently. Turning vocabulary learning off stops observation and clears pending suggestions. The main window is organized as Home / History / Dictionary / Settings. The Dictionary tab opens a separate editor window when you click "New". The Home tab shows total dictation time, total characters, average characters per minute, estimated time saved, and dictionary participation statistics. diff --git a/README.zh.md b/README.zh.md index 7443e9663..6bfad563a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -378,16 +378,16 @@ OpenLess 的润色模型只重塑文本。它不回答问题、不执行任务 - 手动添加正确拼写、分类与备注。你无需维护错误拼写或上下文提示。 - 启用的条目作为 Volcengine ASR 的 `context.hotwords` 发送,以便在转写时被正确识别。 - 条目同样注入润色提示词:模型逐句判断是否替换。如果“Cloud”在上下文中明显指 AI 产品 `Claude`,就会被纠正;如果它确实指云计算,则保持原样。 -- **词典会自己长。** 当你手动改掉 OpenLess 刚打出来的某个词,屏幕角落会弹一张小卡片问你要不要记住它。点一下勾就进去了——不用打开设置页,不用填表。**每一条都由你过目,没有任何东西是悄悄加进去的。** 需要开启下面的「光标上下文」,目前仅 macOS。 +- **手改学词(实验)。** 在「设置 → 隐私 → 数据存储」单独开启,默认关闭,不跟随光标上下文或云同步授权。macOS、Windows 和 Android 可在支持的编辑器中观察插入后的修改,最长 60 秒;候选需在约 10 秒内逐条确认才加入词典。Android 需要无障碍服务。无法观察时,可在历史详情点击「记住词汇」手动输入正确词;Android 输入法编辑结果也提供默认不勾选的加入词典选项。所有入口均需明确确认,不自动创建全局替换规则。 - **真正在用的词优先。** 发给 ASR 的热词预算是有限的(几百字符)。条目按命中次数排序,并给刚手动添加的词留几个保底席位——这样你天天在用的那些词不会被「最近刚加的」挤出去。 ### 光标上下文(需手动开启,仅 macOS) 设置 → 隐私 → 数据存储 → **光标上下文**。默认关闭。 -开启后,每次听写会读取**你正在写的那个应用里**光标附近的几百个字,随润色请求一起发出,让模型知道你在写什么。中文同音词(接口/借口、大鱼/大禹)声学模型分不出来,但上下文能分。词典的自我学习也建立在这之上——OpenLess 只有看得见自己刚打出去的文字,才可能发现你把某个词改掉了。 +开启后,每次听写会读取**你正在写的那个应用里**光标附近的几百个字,随润色请求一起发出,让模型知道你在写什么。中文同音词(接口/借口、大鱼/大禹)声学模型分不出来,但上下文能分。手改学词是独立的本地功能,不需要开启此设置。观察文本本身不会发送给模型;确认加入词典的词会按词典规则参与后续 ASR/润色。 -**永远不读的地方**:密码输入框、macOS Secure Input、密码管理器、终端——这些在发出任何一次辅助功能调用之前就被拦下。开关关闭时,一次辅助功能调用都不会发生,提示词与没有这个功能的版本逐字节相同。 +光标上下文排除密码输入框、macOS Secure Input、已知密码管理器和终端。关闭此开关后不会为润色读取光标上下文;其他已授权的辅助功能(如手改学词或插入)独立工作。手改学词关闭后会停止观察并清空待确认建议。 主窗口组织为 首页 / 历史 / 词典 / 设置。点击“新建”时,词典页会打开一个独立的编辑窗口。首页展示总听写时长、总字数、平均每分钟字数、估算节省的时间,以及词典参与统计。 diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt index 71e556b28..b093f8404 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt @@ -10,6 +10,10 @@ import android.util.Log class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent?) { val action = intent?.action ?: return + if (action == ACTION_VOCABULARY) { + OpenLessAccessibilityService.handleVocabularyCommand(intent) + return + } val receiver = resultReceiver(intent) ?: return when (action) { ACTION_PASTE -> { @@ -58,6 +62,7 @@ class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { } companion object { + const val ACTION_VOCABULARY = "com.openless.app.accessibility.VOCABULARY" const val ACTION_PASTE = "com.openless.app.accessibility.PASTE" const val ACTION_PING = "com.openless.app.accessibility.PING" const val ACTION_CAPTURE_SELECTED_TEXT = diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index 5b27cab04..5580509c5 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -24,16 +24,214 @@ class OpenLessAccessibilityService : AccessibilityService() { private val mainHandler = Handler(Looper.getMainLooper()) private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } private var lastEditableFocus: AccessibilityNodeInfo? = null + private var vocabularyGeneration = 0L + private var vocabularyNode: AccessibilityNodeInfo? = null + private var vocabularyDeadline = 0L + private var vocabularyStarted = 0L + private var vocabularyCard: android.view.View? = null + private val vocabularyRead = Runnable { readVocabularyText() } + private val vocabularyTimeout = Runnable { stopVocabularyObservation() } + private var vocabularyCardTimeout: Runnable? = null + @Volatile private var vocabularyEpoch = 0L + + private fun postVocabulary(action: () -> Unit) { + val epoch = vocabularyEpoch + mainHandler.post { + if (instance === this && vocabularyEpoch == epoch) action() + } + } + + private fun invalidateVocabularyLifecycle() { + vocabularyEpoch++ + stopVocabularyObservation() + hideVocabularyCard() + } + + private var vocabularyCardRequest = 0L + + private fun requestVocabulary(operation: String, data: Bundle = Bundle(), observation: Boolean = false, callback: (Boolean, Bundle?) -> Unit) { + val epoch = vocabularyEpoch + val generation = vocabularyGeneration + OpenLessVocabularyIpc.request(this, operation, data) { ok, response -> + if (instance === this && vocabularyEpoch == epoch && (!observation || vocabularyGeneration == generation)) { + callback(ok, response) + } + } + } + + private fun refreshVocabularyCard() { + val request = ++vocabularyCardRequest + requestVocabulary("pending") { ok, response -> + if (request == vocabularyCardRequest) showVocabularyCard(if (ok) response?.getString("json") ?: "[]" else "[]") + } + } + + private fun startVocabularyObservation(generation: Long) { + if (generation == 0L || vocabularyGeneration == generation) return + stopVocabularyObservation() + vocabularyGeneration = generation + requestVocabulary("active", Bundle().apply { putLong("generation", generation) }, true) { active, _ -> + if (active) captureVocabularyNode(generation) else stopVocabularyObservation() + } + } + + private fun captureVocabularyNode(generation: Long) { + val root = rootInActiveWindow ?: run { stopVocabularyObservation(); return } + val node = try { root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) } finally { root.recycle() } + if (node == null) { stopVocabularyObservation(); return } + val packageName = node.packageName?.toString()?.lowercase().orEmpty() + if (!node.isEditable || node.isPassword || packageName.isEmpty() || packageName == this.packageName || + listOf("keepass", "bitwarden", "1password", "lastpass", "dashlane", "termux", "password").any { packageName.contains(it) }) { + node.recycle() + stopVocabularyObservation() + return + } + vocabularyGeneration = generation + vocabularyNode = node + vocabularyStarted = android.os.SystemClock.elapsedRealtime() + vocabularyDeadline = vocabularyStarted + 60_000L + mainHandler.post(vocabularyRead) + mainHandler.postDelayed(vocabularyTimeout, 60_000L) + } + + private fun stopVocabularyObservation() { + val generation = vocabularyGeneration + mainHandler.removeCallbacks(vocabularyRead) + mainHandler.removeCallbacks(vocabularyTimeout) + vocabularyNode?.recycle() + vocabularyNode = null + vocabularyGeneration = 0L + if (generation != 0L) { + OpenLessVocabularyIpc.request(this, "stop", Bundle().apply { putLong("generation", generation) }) { _, _ -> } + } + } + + private fun readVocabularyText() { + if (vocabularyNode == null) return + requestVocabulary("active", Bundle().apply { putLong("generation", vocabularyGeneration) }, true) { active, _ -> + if (active) readConfirmedVocabularyText() else stopVocabularyObservation() + } + } + + private fun readConfirmedVocabularyText() { + val node = vocabularyNode ?: return + if (android.os.SystemClock.elapsedRealtime() >= vocabularyDeadline || !node.refresh() || !node.isFocused || node.isPassword) { + stopVocabularyObservation() + return + } + val root = rootInActiveWindow + val sameWindow = root != null && root.windowId == node.windowId + root?.recycle() + if (!sameWindow) { stopVocabularyObservation(); return } + val text = node.text?.toString() + if (text == null || text.length > 20_000) { stopVocabularyObservation(); return } + requestVocabulary("observe", Bundle().apply { + putLong("generation", vocabularyGeneration) + putString("text", text) + }, true) { alive, _ -> + if (!alive) stopVocabularyObservation() + else if (android.os.SystemClock.elapsedRealtime() - vocabularyStarted < 1_000L) { + mainHandler.postDelayed(vocabularyRead, 100L) + } + } + } + + private fun vocabularyEvent(event: AccessibilityEvent) { + val node = vocabularyNode ?: return + if (event.eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED || + event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || + event.eventType == AccessibilityEvent.TYPE_WINDOWS_CHANGED) { + if (!node.refresh() || !node.isFocused || node.isPassword) stopVocabularyObservation() + } + if (event.eventType != AccessibilityEvent.TYPE_VIEW_TEXT_CHANGED) return + val source = event.source ?: return + try { + if (source != node) return + if (event.isPassword || source.isPassword) { stopVocabularyObservation(); return } + mainHandler.removeCallbacks(vocabularyRead) + mainHandler.postDelayed(vocabularyRead, 700L) + } finally { source.recycle() } + } + + private fun hideVocabularyCard() { + vocabularyCardTimeout?.let { mainHandler.removeCallbacks(it) } + vocabularyCardTimeout = null + vocabularyCard?.let { view -> + try { (getSystemService(WINDOW_SERVICE) as android.view.WindowManager).removeView(view) } catch (_: Exception) { } + } + vocabularyCard = null + } + + private fun showVocabularyCard(json: String) { + hideVocabularyCard() + val entries = try { org.json.JSONArray(json) } catch (_: Exception) { return } + val now = System.currentTimeMillis() + val pending = (0 until entries.length()).map { entries.getJSONObject(it) }.filter { it.optLong("expiresAtMs") > now } + if (pending.isEmpty()) return + val layout = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.VERTICAL + setPadding(16, 12, 16, 12) + setBackgroundColor(android.graphics.Color.rgb(38, 38, 42)) + addView(android.widget.TextView(context).apply { text = "记住这个词?"; setTextColor(android.graphics.Color.WHITE) }) + } + for (entry in pending) { + val row = android.widget.LinearLayout(this) + row.addView(android.widget.TextView(this).apply { + text = entry.optString("pattern") + " → " + entry.optString("replacement") + setTextColor(android.graphics.Color.WHITE) + maxLines = 2 + }, android.widget.LinearLayout.LayoutParams(0, -2, 1f)) + for (accept in listOf(true, false)) { + row.addView(android.widget.Button(this).apply { + text = if (accept) "✓" else "×" + contentDescription = if (accept) "加入词典" else "忽略" + setOnClickListener { + isEnabled = false + requestVocabulary("resolve", Bundle().apply { + putString("id", entry.getString("id")) + putBoolean("accept", accept) + }) { ok, _ -> + isEnabled = true + if (ok) refreshVocabularyCard() + else android.widget.Toast.makeText(this@OpenLessAccessibilityService, "保存失败,请重试", android.widget.Toast.LENGTH_SHORT).show() + } + } + }, android.widget.LinearLayout.LayoutParams((56 * resources.displayMetrics.density).toInt(), -2)) + } + layout.addView(row) + } + val params = android.view.WindowManager.LayoutParams( + (340 * resources.displayMetrics.density).toInt().coerceAtMost(resources.displayMetrics.widthPixels), + android.view.WindowManager.LayoutParams.WRAP_CONTENT, + android.view.WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY, + android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + android.graphics.PixelFormat.TRANSLUCENT + ).apply { gravity = android.view.Gravity.TOP or android.view.Gravity.END; y = (48 * resources.displayMetrics.density).toInt() } + try { + (getSystemService(WINDOW_SERVICE) as android.view.WindowManager).addView(layout, params) + vocabularyCard = layout + val timeout = Runnable { refreshVocabularyCard() } + vocabularyCardTimeout = timeout + mainHandler.postDelayed(timeout, (pending.minOf { it.getLong("expiresAtMs") } - now).coerceAtLeast(1L)) + } catch (_: Exception) { hideVocabularyCard() } + } override fun onServiceConnected() { super.onServiceConnected() instance = this + vocabularyEpoch++ + refreshVocabularyCard() + requestVocabulary("current") { ok, response -> + val generation = if (ok) response?.getLong("generation", 0L) ?: 0L else 0L + if (vocabularyGeneration == 0L) startVocabularyObservation(generation) + } updateKeyboardOverlayState() scheduleKeyboardOverlayRefresh() } override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event == null) return + vocabularyEvent(event) when (event.eventType) { AccessibilityEvent.TYPE_VIEW_CLICKED -> rememberFocusedEditable(event) AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, @@ -51,9 +249,10 @@ class OpenLessAccessibilityService : AccessibilityService() { } } - override fun onInterrupt() = Unit + override fun onInterrupt() { invalidateVocabularyLifecycle() } override fun onDestroy() { + invalidateVocabularyLifecycle() mainHandler.removeCallbacks(keyboardRefreshRunnable) invalidateEditableCache() if (instance === this) { @@ -427,6 +626,31 @@ class OpenLessAccessibilityService : AccessibilityService() { } companion object { + private fun sendVocabularyCommand(operation: String, generation: Long = 0L) { + val context = OpenLessAppContext.context ?: return + val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { + action = OpenLessAccessibilityCommandReceiver.ACTION_VOCABULARY + putExtra("operation", operation) + putExtra("generation", generation) + } + try { context.sendBroadcast(intent) } catch (_: Throwable) { } + } + + internal fun handleVocabularyCommand(intent: Intent) { + val service = instance ?: return + val generation = intent.getLongExtra("generation", 0L) + service.postVocabulary { + when (intent.getStringExtra("operation")) { + "arm" -> service.startVocabularyObservation(generation) + "disarm" -> if (service.vocabularyGeneration == generation) service.stopVocabularyObservation() + "refresh" -> service.refreshVocabularyCard() + } + } + } + + @Keep @JvmStatic fun armVocabularyObservation(generation: Long) = sendVocabularyCommand("arm", generation) + @Keep @JvmStatic fun disarmVocabularyObservation(generation: Long) = sendVocabularyCommand("disarm", generation) + @Keep @JvmStatic fun showVocabularySuggestions(json: String) = sendVocabularyCommand("refresh") /** Matches [isEnabled] / Settings.Secure component id format (full class name). */ @JvmStatic fun serviceComponentId(): String = diff --git a/openless-all/app/android/kotlin/OpenLessImeService.kt b/openless-all/app/android/kotlin/OpenLessImeService.kt index 297bd2eb5..d7d351e99 100644 --- a/openless-all/app/android/kotlin/OpenLessImeService.kt +++ b/openless-all/app/android/kotlin/OpenLessImeService.kt @@ -644,7 +644,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt ) correctionToggleRow.addView( TextView(this).apply { - text = ui("同时加入纠错规则(下次自动改正)", "Also add as a correction rule") + text = ui("同时将正确词汇加入词典", "Also remember the corrected word") textSize = 12f setTextColor(tone(Color.rgb(180, 180, 180), Color.rgb(120, 120, 125))) }, @@ -2543,7 +2543,7 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt editingReplacesWholeResult = true } editingForClipboardCorrection = false - addCorrectionRuleForEdit = true + addCorrectionRuleForEdit = false editingDictationResult = true awaitingEditReplacement = true refreshInputView() @@ -2618,8 +2618,8 @@ class OpenLessImeService : InputMethodService(), OpenLessOverlayBridge.OverlaySt connection.commitText(text, 1) } if (shouldAddRule && text != original) { - runNativeAction("记录纠错") { - OpenLessNative.nativeAddCorrectionRule(original, text) + runNativeAction("加入词典") { + check(OpenLessNative.nativeAddLearnedVocabulary(text)) { "词典保存失败,请在历史中重试" } } } // A sub-span correction leaves the surrounding text's exact new diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index e4731a6bd..c6841051f 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -27,6 +27,13 @@ object OpenLessNative { @JvmStatic external fun nativeStopDictationAsQuickNote() @JvmStatic external fun nativeCancelDictation() + @JvmStatic external fun nativeObserveVocabularyText(generation: Long, text: String): Boolean + @JvmStatic external fun nativeVocabularyObservationActive(generation: Long): Boolean + @JvmStatic external fun nativeCurrentVocabularyObservation(): Long + @JvmStatic external fun nativeStopVocabularyObservation(generation: Long) + @JvmStatic external fun nativePendingVocabularySuggestions(): String + @JvmStatic external fun nativeResolveVocabularySuggestion(id: String, accept: Boolean): Boolean + @JvmStatic external fun nativeAddLearnedVocabulary(phrase: String): Boolean /** Records a hand-corrected span from the IME's "edit result" flow into the shared correction dictionary. */ @JvmStatic external fun nativeAddCorrectionRule(pattern: String, replacement: String) diff --git a/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt b/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt new file mode 100644 index 000000000..c6c36aaa4 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt @@ -0,0 +1,73 @@ +package com.openless.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver + +/** Runs in the main process, where Core owns consent, generations and vocabulary. */ +class OpenLessVocabularyReceiver : BroadcastReceiver() { + @Suppress("DEPRECATION") + override fun onReceive(context: Context, intent: Intent?) { + if (intent == null) return + val reply = intent.getParcelableExtra("reply") as? ResultReceiver ?: return + val response = Bundle() + val ok = try { + when (intent.getStringExtra("operation")) { + "current" -> { + response.putLong("generation", OpenLessNative.nativeCurrentVocabularyObservation()) + true + } + "stop" -> { + OpenLessNative.nativeStopVocabularyObservation(intent.getLongExtra("generation", 0)) + true + } + "active" -> OpenLessNative.nativeVocabularyObservationActive(intent.getLongExtra("generation", 0)) + "observe" -> { + val text = intent.getStringExtra("text") + text != null && text.length <= 20_000 && OpenLessNative.nativeObserveVocabularyText(intent.getLongExtra("generation", 0), text) + } + "pending" -> { + response.putString("json", OpenLessNative.nativePendingVocabularySuggestions()) + true + } + "resolve" -> OpenLessNative.nativeResolveVocabularySuggestion(intent.getStringExtra("id").orEmpty(), intent.getBooleanExtra("accept", false)) + else -> false + } + } catch (_: Throwable) { false } + reply.send(if (ok) 1 else 0, response) + } +} + +/** Explicit non-exported IPC. No Rust singleton is accessed from :accessibility. */ +internal object OpenLessVocabularyIpc { + fun request(context: Context, operation: String, data: Bundle = Bundle(), callback: (Boolean, Bundle?) -> Unit) { + val handler = Handler(Looper.getMainLooper()) + var completed = false // read and written only on this handler's looper + val timeout = Runnable { + if (!completed) { completed = true; callback(false, null) } + } + val reply = object : ResultReceiver(handler) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + if (completed) return + completed = true + handler.removeCallbacks(timeout) + callback(resultCode == 1, resultData) + } + } + handler.postDelayed(timeout, 500L) + try { + context.sendBroadcast(Intent(context, OpenLessVocabularyReceiver::class.java).apply { + putExtras(data) + putExtra("operation", operation) + putExtra("reply", reply) + }) + } catch (_: Throwable) { + handler.removeCallbacks(timeout) + if (!completed) { completed = true; callback(false, null) } + } + } +} diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 4d41ffc8c..0a8346f60 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2148,6 +2148,7 @@ impl EditObservationSink for CoreEditObservationSink { &self.events, rule.pattern, rule.replacement, + Some((&self.generation, self.expected_generation)), ) { log::warn!("failed to queue observed correction: {error}"); } @@ -2160,6 +2161,7 @@ fn queue_pending_correction_state( events: &Arc, pattern: String, replacement: String, + generation: Option<(&AtomicU64, u64)>, ) -> Result, BackendError> { if pattern.trim().is_empty() || replacement.trim().is_empty() { return Err(BackendError::new( @@ -2169,6 +2171,12 @@ fn queue_pending_correction_state( } let (suggestion, snapshot) = { let mut pending = pending.lock().expect("pending correction lock poisoned"); + if generation.is_some_and(|(current, expected)| current.load(Ordering::Acquire) != expected) + { + return Ok(None); + } + let now = chrono::Utc::now().timestamp_millis(); + pending.retain(|item| item.expires_at_ms > now); if pending .iter() .any(|item| item.pattern == pattern && item.replacement == replacement) @@ -2180,6 +2188,7 @@ fn queue_pending_correction_state( } let suggestion = PendingCorrection { id: uuid::Uuid::new_v4().to_string(), + expires_at_ms: now + crate::shared_types::VOCAB_SUGGESTION_TTL_MS as i64, pattern, replacement, }; @@ -4500,8 +4509,9 @@ impl OpenLessBackend { .reset(); } - if previous.cursor_context_enabled && !preferences.cursor_context_enabled { + if previous.vocabulary_learning_enabled && !preferences.vocabulary_learning_enabled { self.disarm_edit_observation(); + self.dismiss_pending_corrections(); } self.publish_preferences_changed(); Ok(crate::SettingsUpdateOutcome { @@ -4942,10 +4952,12 @@ impl OpenLessBackend { /// Return the instance-local correction suggestions awaiting a user /// decision. The returned value is owned and safe to render on any host. pub fn pending_corrections(&self) -> Vec { - self.pending_corrections + let mut pending = self + .pending_corrections .lock() - .expect("pending correction lock poisoned") - .clone() + .expect("pending correction lock poisoned"); + pending.retain(|item| item.expires_at_ms > chrono::Utc::now().timestamp_millis()); + pending.clone() } /// Queue one observed manual correction. Duplicate pairs are ignored and @@ -4960,6 +4972,7 @@ impl OpenLessBackend { &self.events, pattern, replacement, + None, ) } @@ -4996,7 +5009,7 @@ impl OpenLessBackend { } self.disarm_edit_observation(); if !enabled - || !self.get_preferences().cursor_context_enabled + || !self.get_preferences().vocabulary_learning_enabled || !matches!(insert_outcome, Some(InsertOutcome::Inserted)) || typed_text.trim().is_empty() { @@ -5041,6 +5054,14 @@ impl OpenLessBackend { return Ok(None); }; let suggestion = pending[index].clone(); + if suggestion.expires_at_ms <= chrono::Utc::now().timestamp_millis() { + pending.remove(index); + self.events.publish( + None, + BackendEventKind::VocabularySuggestionsChanged(pending.clone()), + ); + return Ok(None); + } let added = self.vocabulary.add_if_absent( suggestion.replacement.clone(), Some(LEARNED_VOCAB_NOTE.to_string()), @@ -5101,6 +5122,23 @@ impl OpenLessBackend { } } + /// Explicit user-confirmed vocabulary collection from an in-app editor. + /// This path intentionally does not require permission to read other apps. + pub fn add_learned_vocabulary( + &self, + phrase: String, + ) -> Result, BackendError> { + let phrase = phrase.trim(); + if phrase.is_empty() || phrase.chars().count() > 64 || phrase.chars().any(char::is_control) + { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "enter a word or phrase of 1–64 characters", + )); + } + self.add_vocabulary_if_absent(phrase.to_owned(), Some(LEARNED_VOCAB_NOTE.to_owned())) + } + pub fn add_vocabulary( &self, phrase: String, @@ -6671,6 +6709,60 @@ mod tests { fn assert_send_sync() {} + #[test] + fn vocabulary_learning_expired_suggestions_cannot_be_accepted() { + let (backend, _) = backend(); + let suggestion = backend + .queue_pending_correction("错词".into(), "Codex".into()) + .unwrap() + .unwrap(); + backend.pending_corrections.lock().unwrap()[0].expires_at_ms = 0; + assert!(backend + .accept_pending_correction(&suggestion.id) + .unwrap() + .is_none()); + assert!(backend.list_vocabulary().unwrap().is_empty()); + assert!(backend.pending_corrections().is_empty()); + } + + #[test] + fn vocabulary_learning_explicit_collection_is_confirmed_and_idempotent() { + let (backend, _) = backend(); + assert!(!backend.get_preferences().vocabulary_learning_enabled); + let word = backend + .add_learned_vocabulary(" Codex ".into()) + .unwrap() + .unwrap(); + assert_eq!(word.phrase, "Codex"); + assert_eq!(word.note.as_deref(), Some(LEARNED_VOCAB_NOTE)); + assert!(backend + .add_learned_vocabulary("Codex".into()) + .unwrap() + .is_none()); + assert!(backend.add_learned_vocabulary("\n".into()).is_err()); + assert!(backend + .add_learned_vocabulary("first\nsecond".into()) + .is_err()); + assert!(backend.add_learned_vocabulary("a".repeat(65)).is_err()); + assert_eq!(backend.list_vocabulary().unwrap().len(), 1); + } + + #[test] + fn vocabulary_learning_old_generation_cannot_queue_after_opt_out() { + let (backend, _) = backend(); + let generation = AtomicU64::new(2); + assert!(queue_pending_correction_state( + &backend.pending_corrections, + &backend.events, + "错词".into(), + "Codex".into(), + Some((&generation, 1)) + ) + .unwrap() + .is_none()); + assert!(backend.pending_corrections().is_empty()); + } + struct TestDataDir { path: std::path::PathBuf, } @@ -10043,7 +10135,11 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); - preferences.cursor_context_enabled = true; + preferences.vocabulary_learning_enabled = true; + assert!( + !preferences.cursor_context_enabled, + "learning must not require uploading cursor context" + ); backend.set_preferences(preferences).unwrap(); backend.start().await.unwrap(); backend.start_dictation().await.unwrap(); @@ -10064,7 +10160,7 @@ mod tests { backend.dismiss_pending_corrections(); let mut preferences = backend.get_preferences(); - preferences.cursor_context_enabled = false; + preferences.vocabulary_learning_enabled = false; backend .update_settings( preferences, @@ -10077,7 +10173,7 @@ mod tests { } #[tokio::test] - async fn disabled_cursor_context_is_not_rearmed_by_an_older_dictation() { + async fn disabled_vocabulary_learning_is_not_rearmed_by_an_older_dictation() { for streaming in [false, true] { let data_dir = TestDataDir::new("privacy-disabled-during-dictation"); let observation = Arc::new(FakeEditObservation::default()); @@ -10102,13 +10198,13 @@ mod tests { ) .unwrap(); let mut preferences = backend.get_preferences(); - preferences.cursor_context_enabled = true; + preferences.vocabulary_learning_enabled = true; preferences.streaming_insert = streaming; backend.set_preferences(preferences).unwrap(); backend.start().await.unwrap(); backend.start_dictation().await.unwrap(); let mut preferences = backend.get_preferences(); - preferences.cursor_context_enabled = false; + preferences.vocabulary_learning_enabled = false; backend .update_settings( preferences, @@ -10234,7 +10330,7 @@ mod tests { .unwrap(), ); let mut preferences = backend.get_preferences(); - preferences.cursor_context_enabled = true; + preferences.vocabulary_learning_enabled = true; backend.set_preferences(preferences).unwrap(); backend.start().await.unwrap(); let first = backend.start_dictation().await.unwrap(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 5c821db5c..03274dc27 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -655,6 +655,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "local_consent_or_secret", }, + PreferenceField { + rust_name: "vocabulary_learning_enabled", + key: "vocabularyLearningEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "show_overview_activity_heatmap", key: "showOverviewActivityHeatmap", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs index b6c6fb57e..c62a51ed8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -122,7 +122,7 @@ fn all_current_preference_fields_have_an_explicit_registration() { fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { let mut data = channel_snapshot(); data.preferences = SecretJson::new( - json!({"themeMode":"dark","remoteInputPin":"fixture-pin-never-export","codingAgentEnabled":true,"cursorContextEnabled":true,"marketplaceDevLogin":"fixture-login","splashSeenVersion":"2","activeLlmProvider":"llm-one","codingAgentExe":"/fixture/never-run","futureAppearance":{"accent":"violet"}}), + json!({"themeMode":"dark","remoteInputPin":"fixture-pin-never-export","codingAgentEnabled":true,"cursorContextEnabled":true,"vocabularyLearningEnabled":true,"marketplaceDevLogin":"fixture-login","splashSeenVersion":"2","activeLlmProvider":"llm-one","codingAgentExe":"/fixture/never-run","futureAppearance":{"accent":"violet"}}), ); let exported = export_snapshot(data).unwrap(); let raw = serde_json::to_string(exported.documents.documents()).unwrap(); @@ -133,6 +133,7 @@ fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_gran assert!(!raw.contains("fixture-pin-never-export")); assert!(!raw.contains("codingAgentEnabled")); assert!(!raw.contains("cursorContextEnabled")); + assert!(!raw.contains("vocabularyLearningEnabled")); assert!(!raw.contains("fixture-login")); assert!(!raw.contains("activeLlmProvider")); assert!(raw.contains("futureAppearance")); diff --git a/openless-all/app/crates/openless-core/src/dictation_context.rs b/openless-all/app/crates/openless-core/src/dictation_context.rs index 439227371..f44b36115 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -331,7 +331,7 @@ impl DictationContext { options.output_target, DictationOutputTarget::QuickNote | DictationOutputTarget::Qa ), - observe_edits: preferences.cursor_context_enabled, + observe_edits: preferences.vocabulary_learning_enabled, // An undecided Android capture must not stream text into the // foreground app before the terminal gesture classifies it. streaming: !matches!( diff --git a/openless-all/app/crates/openless-core/src/host_document/mod.rs b/openless-all/app/crates/openless-core/src/host_document/mod.rs index 9dbd05e95..79c4d940a 100644 --- a/openless-all/app/crates/openless-core/src/host_document/mod.rs +++ b/openless-all/app/crates/openless-core/src/host_document/mod.rs @@ -3,11 +3,13 @@ //! AX/IME/clipboard 读取仍由宿主实现;Core 只提供可测试的纯函数。 mod diff; +mod observation; mod window; pub use diff::{ edit_is_within_typed_text, is_vocab_worthy, learned_rule, minimal_edit, EditPair, LearnedRule, }; +pub use observation::ObservedInsertion; pub use window::{plan_window, utf16_offset_to_char_offset, window_around_cursor, WindowSpan}; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/openless-all/app/crates/openless-core/src/host_document/observation.rs b/openless-all/app/crates/openless-core/src/host_document/observation.rs new file mode 100644 index 000000000..82a0a5f0c --- /dev/null +++ b/openless-all/app/crates/openless-core/src/host_document/observation.rs @@ -0,0 +1,158 @@ +use super::{minimal_edit, EditPair}; + +/// A bounded document snapshot anchored to one unambiguous insertion. +/// Native observers must additionally retain and verify the editor identity. +/// Offsets here are Unicode scalar offsets, never platform UTF-16 offsets. +pub struct ObservedInsertion { + baseline: String, + start: usize, + end: usize, + valid: bool, +} + +impl ObservedInsertion { + pub const MAX_DOCUMENT_UTF16: usize = 20_000; + + /// Prove that a new, unique insertion replaced one contiguous region. Merely + /// finding the same text in an unchanged document is not a delivery receipt. + pub fn delivered(before: &str, after: &str, inserted: &str) -> bool { + if before == after || before.encode_utf16().count() > Self::MAX_DOCUMENT_UTF16 { + return false; + } + if Self::new(after.to_owned(), inserted).is_none() { + return false; + } + let start = after.find(inserted).unwrap(); + let prefix = &after[..start]; + let suffix = &after[start + inserted.len()..]; + before.len() >= prefix.len() + suffix.len() + && before.starts_with(prefix) + && before.ends_with(suffix) + } + + pub fn new(document: String, inserted: &str) -> Option { + if inserted.trim().is_empty() || document.encode_utf16().count() > Self::MAX_DOCUMENT_UTF16 + { + return None; + } + // Include overlapping occurrences: "aaa" contains "aa" twice. + let mut matches = document + .char_indices() + .filter_map(|(i, _)| document[i..].starts_with(inserted).then_some(i)); + let byte_start = matches.next()?; + if matches.next().is_some() { + return None; + } + let start = document[..byte_start].chars().count(); + Some(Self { + baseline: document, + start, + end: start + inserted.chars().count(), + valid: true, + }) + } + + /// Call after the editor settles. A rejected deletion retains the baseline + /// so delete-then-type remains a replacement. An out-of-span change ends + /// this observation rather than guessing how the insertion moved. + pub fn observe(&mut self, current: &str, publish: impl FnOnce(EditPair) -> bool) -> bool { + if !self.valid { + return false; + } + if current.encode_utf16().count() > Self::MAX_DOCUMENT_UTF16 { + self.valid = false; + return false; + } + if current == self.baseline { + return true; + } + let old: Vec = self.baseline.chars().collect(); + let new: Vec = current.chars().collect(); + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let suffix_limit = (old.len() - prefix).min(new.len() - prefix); + let suffix = (0..suffix_limit) + .take_while(|i| old[old.len() - 1 - i] == new[new.len() - 1 - i]) + .count(); + let old_end = old.len() - suffix; + if prefix < self.start || old_end > self.end { + self.valid = false; + return false; + } + // Supply only insertion-local context. Single-character vocabulary + // expansion must not pull unrelated neighboring document text in. + let old_span: String = old[self.start..self.end].iter().collect(); + let new_end = self.end - (old_end - prefix) + (new.len() - suffix - prefix); + let new_span: String = new[self.start..new_end].iter().collect(); + if let Some(edit) = minimal_edit(&old_span, &new_span) { + if publish(edit) { + self.baseline = current.to_owned(); + self.end = new_end; + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn delivery_requires_a_real_insertion_and_unchanged_surroundings() { + assert!(!ObservedInsertion::delivered( + "existing word", + "existing word", + "word" + )); + assert!(ObservedInsertion::delivered("前😀后", "前😀新词后", "新词")); + assert!(ObservedInsertion::delivered("前旧词后", "前新词后", "新词")); + assert!(!ObservedInsertion::delivered( + "前旧词后", + "别处新词后", + "新词" + )); + assert!(!ObservedInsertion::delivered("word", "word word", "word")); + } + + #[test] + fn rejects_ambiguous_and_missing_insertions() { + assert!(ObservedInsertion::new("aaa".into(), "aa").is_none()); + assert!(ObservedInsertion::new("old".into(), "new").is_none()); + assert!(ObservedInsertion::new(" ".into(), " ").is_none()); + } + + #[test] + fn same_word_outside_insertion_ends_observation() { + let mut observed = ObservedInsertion::new("大禹。今天讲大禹".into(), "今天讲大禹").unwrap(); + assert!(!observed.observe("大鱼。今天讲大禹", |_| panic!("outside insertion"))); + assert!(!observed.observe("大禹。今天讲大鱼", |_| panic!("already ended"))); + } + + #[test] + fn deletion_then_typing_preserves_original_and_unicode_offsets() { + let mut observed = + ObservedInsertion::new("😀前文:今天讲大禹。后文".into(), "今天讲大禹").unwrap(); + assert!(observed.observe("😀前文:今天讲。后文", |_| false)); + let mut pair = None; + assert!(observed.observe("😀前文:今天讲Codex。后文", |edit| { + pair = Some(edit); + true + })); + let pair = pair.unwrap(); + assert_eq!(pair.source, "大禹"); + assert_eq!(pair.target, "Codex"); + assert_eq!(pair.before, "今天讲"); + assert_eq!(pair.after, ""); + assert!(observed.observe("😀前文:今天讲Claude。后文", |edit| { + assert_eq!(edit.source, "odex"); + true + })); + } + + #[test] + fn oversized_documents_are_never_observed() { + assert!(ObservedInsertion::new("a".repeat(20_001), "a").is_none()); + let mut observed = ObservedInsertion::new("hello".into(), "hello").unwrap(); + assert!(!observed.observe(&"a".repeat(20_001), |_| panic!("oversized"))); + } +} diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index ec66b357e..8a3b7d374 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -246,6 +246,8 @@ pub use crate::types::{CorrectionRule, RuleSource}; #[serde(rename_all = "camelCase")] pub struct PendingCorrection { pub id: String, + /// Absolute deadline shared by native and web confirmation surfaces. + pub expires_at_ms: i64, /// 改之前那个(错的)写法。只用来在卡片上让用户看清改的是什么,不入库。 pub pattern: String, /// 用户最后要的那个词 —— 点「好」之后进词汇表的就是它。 @@ -646,6 +648,9 @@ pub struct UserPreferences { /// 密码框 / Secure Input / 密码管理器 / 终端一律硬拦,与本开关无关。 #[serde(default)] pub cursor_context_enabled: bool, + /// Observe corrections locally after insertion; independent of LLM context. + #[serde(default)] + pub vocabulary_learning_enabled: bool, /// 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片, /// 活动计数照常记录(persistence/activity.rs),再打开时全年数据仍在。 #[serde(default = "default_true")] @@ -953,6 +958,8 @@ struct UserPreferencesWire { streaming_insert_save_clipboard: bool, #[serde(default)] cursor_context_enabled: bool, + #[serde(default)] + vocabulary_learning_enabled: bool, #[serde(default = "default_true")] show_overview_activity_heatmap: bool, #[serde(default)] @@ -1126,6 +1133,7 @@ impl Default for UserPreferencesWire { streaming_insert_default_migrated: prefs.streaming_insert_default_migrated, streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, cursor_context_enabled: prefs.cursor_context_enabled, + vocabulary_learning_enabled: prefs.vocabulary_learning_enabled, show_overview_activity_heatmap: prefs.show_overview_activity_heatmap, stacked_row_layout: prefs.stacked_row_layout, conservative_layout: prefs.conservative_layout, @@ -1328,6 +1336,7 @@ impl<'de> Deserialize<'de> for UserPreferences { streaming_insert_default_migrated: true, streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, cursor_context_enabled: wire.cursor_context_enabled, + vocabulary_learning_enabled: wire.vocabulary_learning_enabled, show_overview_activity_heatmap: wire.show_overview_activity_heatmap, stacked_row_layout: wire.stacked_row_layout, conservative_layout: wire.conservative_layout, @@ -1678,6 +1687,7 @@ impl Default for UserPreferences { streaming_insert_default_migrated: true, streaming_insert_save_clipboard: true, cursor_context_enabled: false, + vocabulary_learning_enabled: false, show_overview_activity_heatmap: true, stacked_row_layout: false, conservative_layout: false, diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index 90a36ae1d..83e9bc50b 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -53,6 +53,7 @@ const KOTLIN_FILES = [ 'OpenLessShizukuUserServiceClient.kt', 'ShizukuPermissionActivity.kt', 'OpenLessAccessibilityCommandReceiver.kt', + 'OpenLessVocabularyReceiver.kt', 'OverlayPermissionActivity.kt', 'OpenLessUpdateInstaller.kt', 'OpenLessContentReader.kt', @@ -222,7 +223,10 @@ function mergeStringsXml(dryRun) { 'OpenLess uses accessibility to detect the keyboard and paste dictation results without switching your current keyboard.', openless_ime_label: 'OpenLess Voice', }); - const merged = mergeMissingStringResources(generatedStrings.content, SHIZUKU_STRINGS_BY_LOCALE.values); + const merged = mergeMissingStringResources( + generatedStrings.content, + SHIZUKU_STRINGS_BY_LOCALE.values, + ); if (!dryRun) { writeFileSync(stringsPath, merged.content, 'utf8'); console.log(`Merged OpenLess strings into ${stringsPath}`); @@ -337,7 +341,10 @@ function ensureInstrumentationRunner(dryRun) { function ensureMainActivityOpen(dryRun) { if (!existsSync(mainActivityPath)) return; const existing = readFileSync(mainActivityPath, 'utf8'); - const updated = existing.replace(/(?:open\s+)*class MainActivity\s*:/, 'open class MainActivity:'); + const updated = existing.replace( + /(?:open\s+)*class MainActivity\s*:/, + 'open class MainActivity:', + ); if (updated === existing) return; if (dryRun) { console.log(`[dry-run] Would make MainActivity inheritable for background warmup`); diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.mjs index 72439b8dc..78421d394 100644 --- a/openless-all/app/scripts/merge-android-overlay-manifest.mjs +++ b/openless-all/app/scripts/merge-android-overlay-manifest.mjs @@ -56,6 +56,9 @@ const SERVICE_SNIPPETS = [ android:name="android.accessibilityservice" android:resource="@xml/openless_accessibility_config" /> `, + ``, `, + started: Instant, + callback: Box bool + Send + Sync>, +} +static OBSERVATION: Mutex> = Mutex::new(None); +static NEXT: AtomicU64 = AtomicU64::new(1); + +pub fn arm(text: String, callback: Box bool + Send + Sync>) -> Option { + let generation = NEXT.fetch_add(1, Ordering::AcqRel); + *OBSERVATION.lock().ok()? = Some(Observation { + generation, + text, + anchor: None, + started: Instant::now(), + callback, + }); + let result = super::jni::android::with_android_env(|env, context| { + let class = super::jni::android::load_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + )?; + env.call_static_method( + class, + "armVocabularyObservation", + "(J)V", + &[JValue::Long(generation as i64)], + ) + .map_err(|e| e.to_string())?; + Ok(()) + }); + if result.is_err() { + disarm(generation); + return None; + } + // A disconnected accessibility process may never answer. Release retained + // text even when no callback arrives to enforce the deadline for us. + tauri::async_runtime::spawn(async move { + tokio::time::sleep(Duration::from_secs(1)).await; + let unanchored = OBSERVATION.lock().ok().is_some_and(|slot| { + slot.as_ref() + .is_some_and(|o| o.generation == generation && o.anchor.is_none()) + }); + if unanchored { + disarm(generation); + return; + } + tokio::time::sleep(Duration::from_secs(59)).await; + disarm(generation); + }); + Some(generation) +} + +pub fn disarm(generation: u64) { + let removed = if let Ok(mut current) = OBSERVATION.lock() { + if current.as_ref().is_some_and(|o| o.generation == generation) { + *current = None; + true + } else { + false + } + } else { + false + }; + if !removed { + return; + } + let _ = super::jni::android::with_android_env(|env, context| { + let class = super::jni::android::load_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + )?; + env.call_static_method( + class, + "disarmVocabularyObservation", + "(J)V", + &[JValue::Long(generation as i64)], + ) + .map_err(|e| e.to_string())?; + Ok(()) + }); +} + +#[no_mangle] +pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeCurrentVocabularyObservation( + _: JNIEnv, + _: JClass, +) -> jlong { + let Ok(mut slot) = OBSERVATION.lock() else { + return 0; + }; + if slot.as_ref().is_some_and(|o| { + o.started.elapsed() >= Duration::from_secs(60) + || (o.anchor.is_none() && o.started.elapsed() >= Duration::from_secs(1)) + }) { + *slot = None; + } + slot.as_ref().map_or(0, |o| o.generation as jlong) +} + +#[no_mangle] +pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopVocabularyObservation( + _: JNIEnv, + _: JClass, + generation: jlong, +) { + disarm(generation as u64); +} + +#[no_mangle] +pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeVocabularyObservationActive( + _: JNIEnv, + _: JClass, + generation: jlong, +) -> jboolean { + OBSERVATION.lock().ok().is_some_and(|slot| { + slot.as_ref().is_some_and(|o| { + o.generation == generation as u64 && o.started.elapsed() < Duration::from_secs(60) + }) + }) as jboolean +} + +#[no_mangle] +pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeObserveVocabularyText( + mut env: JNIEnv, + _: JClass, + generation: jlong, + text: JString, +) -> jboolean { + let Ok(text) = env.get_string(&text) else { + return 0; + }; + let text: String = text.into(); + let Ok(mut slot) = OBSERVATION.lock() else { + return 0; + }; + let Some(observation) = slot.as_mut().filter(|o| o.generation == generation as u64) else { + return 0; + }; + if observation.started.elapsed() >= Duration::from_secs(60) + || (observation.anchor.is_none() && observation.started.elapsed() >= Duration::from_secs(1)) + { + *slot = None; + return 0; + } + let alive = if let Some(anchor) = &mut observation.anchor { + anchor.observe(&text, &observation.callback) + } else { + observation.anchor = ObservedInsertion::new(text, &observation.text); + observation.anchor.is_some() || observation.started.elapsed() < Duration::from_secs(1) + }; + if !alive { + *slot = None; + } + alive as jboolean +} + +pub fn show_suggestions(suggestions: &[crate::types::PendingCorrection]) { + let Ok(json) = serde_json::to_string(suggestions) else { + return; + }; + let _ = super::jni::android::with_android_env(|env, context| { + let class = super::jni::android::load_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + )?; + let json = env.new_string(json).map_err(|e| e.to_string())?; + env.call_static_method( + class, + "showVocabularySuggestions", + "(Ljava/lang/String;)V", + &[JValue::Object(json.as_ref())], + ) + .map_err(|e| e.to_string())?; + Ok(()) + }); +} diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index fed66b08b..428012d25 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -42,7 +42,7 @@ pub mod android { Ok(()) } - fn load_context_class<'local>( + pub(crate) fn load_context_class<'local>( env: &mut JNIEnv<'local>, context: &JObject<'local>, class_name: &str, @@ -389,16 +389,14 @@ pub mod android { plaintext: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("seal", plaintext, aad) - .map_err(classify_keystore_failure) + call_credential_vault_two_arrays("seal", plaintext, aad).map_err(classify_keystore_failure) } pub(crate) fn keystore_open( sealed: &[u8], aad: &[u8], ) -> Result, AndroidKeystoreFailure> { - call_credential_vault_two_arrays("open", sealed, aad) - .map_err(classify_keystore_failure) + call_credential_vault_two_arrays("open", sealed, aad).map_err(classify_keystore_failure) } pub(crate) fn keystore_delete_key() -> Result<(), AndroidKeystoreFailure> { diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index 4771a4006..00d5fa51d 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -2,6 +2,8 @@ pub mod accessibility; #[cfg(target_os = "android")] +pub mod edit_observation; +#[cfg(target_os = "android")] pub mod insert; pub mod insert_tiers; pub mod jni; diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index c5c1928ff..363872dd5 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -507,6 +507,62 @@ mod jni_exports { use jni::sys::{jboolean, jstring, JNIEnv}; use jni::JNIEnv as JniEnv; + #[no_mangle] + pub extern "system" fn Java_com_openless_app_OpenLessNative_nativePendingVocabularySuggestions( + env: JniEnv, + _: JClass, + ) -> jstring { + let pending = CORE_BACKEND + .get() + .map(|b| b.pending_corrections()) + .unwrap_or_default(); + let json = serde_json::to_string(&pending).unwrap_or_else(|_| "[]".to_string()); + env.new_string(json) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) + } + + #[no_mangle] + pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeResolveVocabularySuggestion( + mut env: JniEnv, + _: JClass, + id: JString, + accept: jboolean, + ) -> jboolean { + let Some(backend) = CORE_BACKEND.get() else { + return 0; + }; + let Ok(id) = env.get_string(&id) else { + return 0; + }; + let id: String = id.into(); + if accept != 0 { + backend.accept_pending_correction(&id).is_ok() as jboolean + } else { + backend.reject_pending_correction(&id); + 1 + } + } + + #[no_mangle] + pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeAddLearnedVocabulary( + mut env: JniEnv, + _: JClass, + phrase: JString, + ) -> jboolean { + let Some(backend) = CORE_BACKEND.get() else { + return 0; + }; + let Ok(phrase) = env.get_string(&phrase) else { + return 0; + }; + let phrase: String = phrase.into(); + if phrase.trim().is_empty() { + return 0; + } + backend.add_learned_vocabulary(phrase).is_ok() as jboolean + } + unsafe fn with_jni_context( env_ptr: *mut JNIEnv, context: JObject, diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index 5fb8e3134..b5f6851b3 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -1,5 +1,12 @@ use super::*; +#[tauri::command] +pub fn add_learned_vocab(core: CoreState<'_>, phrase: String) -> Result<(), String> { + core.add_learned_vocabulary(phrase) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub fn list_vocab(core: CoreState<'_>) -> Result, String> { core.list_vocabulary().map_err(|e| e.to_string()) @@ -48,19 +55,19 @@ pub fn add_correction_rule( /// 卡片上点了勾:把这个词收进词汇表,打「自动收集」标记,随时能在词汇表页删掉。 #[tauri::command] -pub fn accept_pending_correction(core: CoreState<'_>, coord: CoordinatorState<'_>, id: String) { +pub fn accept_pending_correction( + core: CoreState<'_>, + coord: CoordinatorState<'_>, + id: String, +) -> Result<(), String> { match core.accept_pending_correction(&id) { - Ok(Some(suggestion)) => { - log::info!( - "[cursor-context] learned vocabulary entry: {:?} (was {:?})", - suggestion.replacement, - suggestion.pattern - ); + Ok(Some(_)) => { coord.refresh_vocab_suggestion_presentation(!core.pending_corrections().is_empty()); } Ok(None) => {} - Err(error) => log::warn!("[cursor-context] accept learned vocabulary failed: {error}"), + Err(error) => return Err(error.to_string()), } + Ok(()) } /// 卡片上点了叉:丢掉这一条,什么都不记(没有拒绝名单)。 diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 046819840..28961c826 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -244,7 +244,7 @@ pub(crate) fn backend_dependencies( )); dependencies.qa_runtime = Some(Arc::new(crate::qa_adapter::TauriQaRuntimeAdapter::new( Arc::clone(&app), - backend, + Arc::clone(&backend), Arc::clone(&credential_store), Arc::clone(&qa_host_context), ))); @@ -267,7 +267,7 @@ pub(crate) fn backend_dependencies( Some(Arc::new(TauriSelectionRuntime::new(Arc::clone(&app)))); dependencies.selection_polisher = Some(polisher); } - dependencies.text_inserter = Arc::new(TauriTextInserter::new(Arc::clone(&app))); + dependencies.text_inserter = Arc::new(TauriTextInserter::new(Arc::clone(&app), backend)); dependencies.host_actions = Arc::new(TauriHostActions::new(app, qa_host_context)); dependencies.dictation_engine = dictation; dependencies.credential_store = credential_store; @@ -946,7 +946,6 @@ impl TauriLocalAsrRuntimeAdapter { )) }) } - } #[derive(Clone)] @@ -2509,17 +2508,21 @@ impl RecordingArchive for TauriRecordingArchive { )); }; let target = crate::persistence::quick_note_recordings_root() - .map_err(|error| BackendError::new(BackendErrorCode::Persistence, error.to_string()))? + .map_err(|error| { + BackendError::new(BackendErrorCode::Persistence, error.to_string()) + })? .join(file_name); if current == target { return Ok(()); } - tokio::fs::rename(¤t, &target).await.map_err(|error| { - BackendError::new( - BackendErrorCode::Persistence, - format!("promote quick-note recording archive: {error}"), - ) - })?; + tokio::fs::rename(¤t, &target) + .await + .map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("promote quick-note recording archive: {error}"), + ) + })?; *path.lock() = target; Ok(()) }) @@ -2536,17 +2539,21 @@ impl RecordingArchive for TauriRecordingArchive { )); }; let target = crate::persistence::recordings_root() - .map_err(|error| BackendError::new(BackendErrorCode::Persistence, error.to_string()))? + .map_err(|error| { + BackendError::new(BackendErrorCode::Persistence, error.to_string()) + })? .join(file_name); if current == target { return Ok(()); } - tokio::fs::rename(¤t, &target).await.map_err(|error| { - BackendError::new( - BackendErrorCode::Persistence, - format!("move recording archive to ordinary storage: {error}"), - ) - })?; + tokio::fs::rename(¤t, &target) + .await + .map_err(|error| { + BackendError::new( + BackendErrorCode::Persistence, + format!("move recording archive to ordinary storage: {error}"), + ) + })?; *path.lock() = target; Ok(()) }) @@ -2746,6 +2753,7 @@ fn map_recorder_error(error: RecorderError) -> BackendError { #[derive(Clone)] pub(crate) struct TauriTextInserter { app: AppHandleSlot, + backend: BackendSlot, insertion_target: Option, #[cfg(target_os = "windows")] windows_ime: Arc, @@ -2773,9 +2781,10 @@ impl EditObservationAdapter for TauriEditObservationAdapter { } impl TauriTextInserter { - fn new(app: AppHandleSlot) -> Self { + fn new(app: AppHandleSlot, backend: BackendSlot) -> Self { Self { app, + backend, insertion_target: None, #[cfg(target_os = "windows")] windows_ime: Arc::new(crate::windows_ime_session::WindowsImeSessionController::new()), @@ -2945,6 +2954,7 @@ impl CoreTextInserter for TauriTextInserter { context: Arc, ) -> BoxFuture<'static, Result, BackendError>> { let app = Arc::clone(&self.app); + let backend = Arc::clone(&self.backend); let insertion_target = self.insertion_target.clone(); #[cfg(target_os = "windows")] let windows_ime = Arc::clone(&self.windows_ime); @@ -3029,6 +3039,7 @@ impl CoreTextInserter for TauriTextInserter { let _ = app; Ok(Arc::new(TauriTextInsertionSession { session_id, + backend, context, insertion_target, finished: Arc::new(AtomicBool::new(false)), @@ -3056,6 +3067,7 @@ impl CoreTextInserter for TauriTextInserter { #[derive(Clone)] struct TauriTextInsertionSession { session_id: SessionId, + backend: BackendSlot, context: Arc, insertion_target: crate::selection::SelectionInsertionTarget, finished: Arc, @@ -3205,11 +3217,33 @@ impl TauriTextInsertionSession { windows_unicode_fallback(&self.context, &text) } openless_core::shared_types::WindowsInsertionMode::Paste => { - crate::insertion::TextInserter::new().insert( - &text, - self.context.insertion.restore_clipboard_after_paste, - self.context.insertion.paste_shortcut, - ) + let backend = Arc::clone(&self.backend); + let observe = self.context.insertion.observe_edits; + let restore = self.context.insertion.restore_clipboard_after_paste; + let shortcut = self.context.insertion.paste_shortcut; + tauri::async_runtime::spawn_blocking(move || { + crate::host_document::insert_with_delivery_check( + &text, + || { + observe + && backend + .lock() + .as_ref() + .and_then(|b| b.upgrade()) + .is_some_and(|b| { + b.get_preferences().vocabulary_learning_enabled + }) + }, + || { + crate::insertion::TextInserter::new() + .insert(&text, restore, shortcut) + }, + ) + }) + .await + .map_err(|error| { + BackendError::new(BackendErrorCode::Internal, error.to_string()) + })? } }; return map_insert_status(status); diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index d6670a2fb..8dd91769b 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -5,9 +5,8 @@ //! //! ## 边界 //! -//! 所有平台差异关在本模块内。非 macOS 一律返回 [`HostDocumentStatus::Unsupported`]: -//! Windows 没有任何 UIAutomation 代码且 TSF 只在提交瞬间激活;Linux 的 fcitx5 -//! SurroundingText 多数客户端不支持。留着接口形状一致,将来补实现不用改调用方。 +//! 光标上下文目前仅 macOS 提供。手改学词使用独立授权:macOS AX、Windows UIA +//! 与 Android 无障碍分别提供短时观察;Linux 保留显式加词入口。 //! //! ## 三条硬约束(新代码不得违反,哪怕仓库里的旧 AX 代码就是这么写的) //! @@ -17,8 +16,8 @@ //! 2. **不在 tokio worker 上同步调 AX**。走 `spawn_blocking` + `tokio::time::timeout` //! 双保险(形状照 `windows_ime_ipc.rs` 的原生调用边界)。内层超时保护线程本身, //! 外层保证 async 调用方无论如何都能按时返回。 -//! 3. **读之前先过安全闸门**。我们读的是别的应用里的任意文本,最终会进 LLM 请求体。 -//! 密码框、Secure Input、密码管理器、终端一律不读,一次 AX 都不发。 +//! 3. **读正文之前先过安全闸门**。密码框、Secure Input、已知密码管理器和终端不读。 +//! 光标上下文可按授权进入 LLM 请求;手改观察文本仅用于本地差分。 //! //! ## 本里程碑的范围 //! @@ -27,6 +26,10 @@ #[cfg(target_os = "macos")] mod macos; +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +pub(crate) use windows::insert_with_delivery_check; #[cfg(target_os = "macos")] pub(crate) use macos::{KeyboardDelivery, KeyboardDeliveryOutcome}; @@ -318,15 +321,19 @@ fn blocked_result(reason: BlockReason) -> HostDocumentReadResult { /// 的每次击键唤醒。所以除了这里的 RAII,观察线程自己还有 60 秒硬超时和「前台 app 一换 /// 就自杀」两道保险。 pub struct EditWatcher { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] stop: std::sync::Arc, + #[cfg(target_os = "android")] + generation: u64, } impl EditWatcher { /// 主动解除。幂等,drop 时会自动调用。 pub fn disarm(&self) { - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] self.stop.store(true, std::sync::atomic::Ordering::Relaxed); + #[cfg(target_os = "android")] + crate::android::edit_observation::disarm(self.generation); } } @@ -356,7 +363,16 @@ where let stop = macos::spawn_edit_watcher(typed_text, Box::new(on_edit))?; Some(EditWatcher { stop }) } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] + { + windows::spawn_edit_watcher(typed_text, Box::new(on_edit)).map(|stop| EditWatcher { stop }) + } + #[cfg(target_os = "android")] + { + crate::android::edit_observation::arm(typed_text, Box::new(on_edit)) + .map(|generation| EditWatcher { generation }) + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "android")))] { let _ = (typed_text, on_edit); None diff --git a/openless-all/app/src-tauri/src/host_document/windows.rs b/openless-all/app/src-tauri/src/host_document/windows.rs new file mode 100644 index 000000000..ea42ec6b6 --- /dev/null +++ b/openless-all/app/src-tauri/src/host_document/windows.rs @@ -0,0 +1,309 @@ +//! Short-lived UIA observation. All registration and COM objects belong to one +//! MTA worker; callbacks only mark text dirty and never read document contents. +use super::EditPair; +use openless_core::host_document::ObservedInsertion; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, OnceLock, +}; +use std::time::{Duration, Instant}; +use windows::core::{implement, Interface, Result, PWSTR, VARIANT}; +use windows::Win32::{ + Foundation::{CloseHandle, HWND}, + System::{ + Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, + COINIT_MULTITHREADED, + }, + Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32, + PROCESS_QUERY_LIMITED_INFORMATION, + }, + }, + UI::{Accessibility::*, WindowsAndMessaging::GetForegroundWindow}, +}; + +type Callback = Box bool + Send + Sync>; + +/// The paste command itself remains authoritative on failures. UIA may only +/// promote PasteSent after observing a change in the very same editor. Failure +/// to read the host never retries, suppresses, or changes the actual paste. +pub(crate) fn insert_with_delivery_check( + text: &str, + consent: impl Fn() -> bool, + insert: impl FnOnce() -> crate::types::InsertStatus, +) -> crate::types::InsertStatus { + use crate::types::InsertStatus; + unsafe { + if !consent() || CoInitializeEx(None, COINIT_MULTITHREADED).is_err() { + return insert(); + } + struct ComGuard; + impl Drop for ComGuard { + fn drop(&mut self) { + unsafe { CoUninitialize() } + } + } + let _com = ComGuard; + let snapshot = (|| -> Result<_> { + let uia: IUIAutomation = CoCreateInstance(&CUIAutomation8, None, CLSCTX_INPROC_SERVER)?; + let timeouts: IUIAutomation2 = uia.cast()?; + timeouts.SetConnectionTimeout(200)?; + timeouts.SetTransactionTimeout(200)?; + let window = GetForegroundWindow(); + let element = uia.GetFocusedElement()?; + if !consent() || element.CurrentIsPassword()?.as_bool() || !allowed_process(&element)? { + return Err(windows::core::Error::from_win32()); + } + let before = read_text(&element)?; + Ok((uia, element, window, before)) + })() + .ok(); + let status = insert(); + if status != InsertStatus::PasteSent { + return status; + } + let Some((uia, element, window, before)) = snapshot else { + return status; + }; + let started = Instant::now(); + while started.elapsed() < Duration::from_secs(1) && consent() { + let verified = (|| -> Result { + if GetForegroundWindow() != window + || !uia + .CompareElements(&element, &uia.GetFocusedElement()?)? + .as_bool() + { + return Err(windows::core::Error::from_win32()); + } + if !consent() { + return Ok(false); + } + let after = read_text(&element)?; + Ok(ObservedInsertion::delivered(&before, &after, text)) + })(); + match verified { + Ok(true) => return InsertStatus::Inserted, + Err(_) => break, + Ok(false) => std::thread::sleep(Duration::from_millis(50)), + } + } + status + } +} +struct Request { + text: String, + window: isize, + stop: Arc, + callback: Callback, +} + +#[implement(IUIAutomationEventHandler, IUIAutomationPropertyChangedEventHandler)] +struct Changed { + dirty: Arc, +} +impl IUIAutomationEventHandler_Impl for Changed_Impl { + fn HandleAutomationEvent( + &self, + _: Option<&IUIAutomationElement>, + _: UIA_EVENT_ID, + ) -> Result<()> { + self.dirty.store(true, Ordering::Release); + Ok(()) + } +} +impl IUIAutomationPropertyChangedEventHandler_Impl for Changed_Impl { + fn HandlePropertyChangedEvent( + &self, + _: Option<&IUIAutomationElement>, + _: UIA_PROPERTY_ID, + _: &VARIANT, + ) -> Result<()> { + self.dirty.store(true, Ordering::Release); + Ok(()) + } +} + +pub(super) fn spawn_edit_watcher(text: String, callback: Callback) -> Option> { + if text.trim().is_empty() { + return None; + } + static WORKER: OnceLock>> = OnceLock::new(); + let worker = WORKER + .get_or_init(|| { + let (tx, rx) = mpsc::channel::(); + std::thread::Builder::new() + .name("vocab-uia".into()) + .spawn(move || unsafe { + if CoInitializeEx(None, COINIT_MULTITHREADED).is_err() { + return; + } + while let Ok(request) = rx.recv() { + if !request.stop.load(Ordering::Acquire) { + let _ = observe(&request); + } + } + CoUninitialize(); + }) + .ok() + .map(|_| tx) + }) + .as_ref()?; + let stop = Arc::new(AtomicBool::new(false)); + worker + .send(Request { + text, + window: unsafe { GetForegroundWindow().0 as isize }, + stop: stop.clone(), + callback, + }) + .ok()?; + Some(stop) +} + +unsafe fn allowed_process(element: &IUIAutomationElement) -> Result { + let pid = element.CurrentProcessId()? as u32; + if pid == std::process::id() { + return Ok(false); + } + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid)?; + let mut buffer = [0u16; 1024]; + let mut len = buffer.len() as u32; + let result = QueryFullProcessImageNameW( + process, + PROCESS_NAME_WIN32, + PWSTR(buffer.as_mut_ptr()), + &mut len, + ); + let _ = CloseHandle(process); + result?; + let path = String::from_utf16_lossy(&buffer[..len as usize]).to_lowercase(); + let name = path.rsplit(['/', '\\']).next().unwrap_or(""); + Ok(![ + "keepass", + "1password", + "bitwarden", + "lastpass", + "dashlane", + "windowsterminal", + "powershell", + "pwsh", + "cmd.exe", + "conhost", + "mintty", + "wezterm", + "alacritty", + "putty", + ] + .iter() + .any(|blocked| name.contains(blocked))) +} + +unsafe fn read_text(element: &IUIAutomationElement) -> Result { + if element.CurrentIsPassword()?.as_bool() { + return Err(windows::core::Error::from_win32()); + } + let text = if let Ok(pattern) = + element.GetCurrentPatternAs::(UIA_TextPatternId) + { + pattern + .DocumentRange()? + .GetText((ObservedInsertion::MAX_DOCUMENT_UTF16 + 1) as i32)? + .to_string() + } else { + element + .GetCurrentPatternAs::(UIA_ValuePatternId)? + .CurrentValue()? + .to_string() + }; + if text.encode_utf16().count() > ObservedInsertion::MAX_DOCUMENT_UTF16 { + return Err(windows::core::Error::from_win32()); + } + Ok(text) +} + +unsafe fn observe(request: &Request) -> Result<()> { + let uia: IUIAutomation = CoCreateInstance(&CUIAutomation8, None, CLSCTX_INPROC_SERVER)?; + let timeouts: IUIAutomation2 = uia.cast()?; + timeouts.SetConnectionTimeout(200)?; + timeouts.SetTransactionTimeout(200)?; + let element = uia.GetFocusedElement()?; + if element.CurrentIsPassword()?.as_bool() || !allowed_process(&element)? { + return Ok(()); + } + let active = || -> Result { + Ok(!request.stop.load(Ordering::Acquire) + && GetForegroundWindow() == HWND(request.window as *mut _) + && !element.CurrentIsPassword()?.as_bool() + && uia + .CompareElements(&element, &uia.GetFocusedElement()?)? + .as_bool()) + }; + let started = Instant::now(); + let mut anchor = loop { + if !active()? || started.elapsed() >= Duration::from_secs(1) { + return Ok(()); + } + if let Some(anchor) = ObservedInsertion::new(read_text(&element)?, &request.text) { + break anchor; + } + std::thread::sleep(Duration::from_millis(100)); + }; + let dirty = Arc::new(AtomicBool::new(true)); + let handler: IUIAutomationEventHandler = Changed { + dirty: dirty.clone(), + } + .into(); + let property: IUIAutomationPropertyChangedEventHandler = handler.cast()?; + let text_registered = uia + .AddAutomationEventHandler( + UIA_Text_TextChangedEventId, + &element, + TreeScope_Element, + None, + &handler, + ) + .is_ok(); + let value_registered = uia + .AddPropertyChangedEventHandlerNativeArray( + &element, + TreeScope_Element, + None, + &property, + &[UIA_ValueValuePropertyId], + ) + .is_ok(); + if !text_registered && !value_registered { + return Ok(()); + } + // Always unregister, including provider errors and opt-out. Late callbacks + // only retain their own dirty flag; no host text or Core sink is accessible. + let result = (|| -> Result<()> { + let mut changed_at = Some(Instant::now()); + while started.elapsed() < Duration::from_secs(60) && active()? { + if dirty.swap(false, Ordering::AcqRel) { + changed_at = Some(Instant::now()); + } + if changed_at.is_some_and(|at| at.elapsed() >= Duration::from_millis(700)) { + changed_at = None; + let current = read_text(&element)?; + if !active()? + || !anchor.observe(¤t, |edit| { + !request.stop.load(Ordering::Acquire) && (request.callback)(edit) + }) + { + break; + } + } + std::thread::sleep(Duration::from_millis(100)); + } + Ok(()) + })(); + if text_registered { + let _ = uia.RemoveAutomationEventHandler(UIA_Text_TextChangedEventId, &element, &handler); + } + if value_registered { + let _ = uia.RemovePropertyChangedEventHandler(&element, &property); + } + result +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 36487b9e1..055cb61f3 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -464,6 +464,7 @@ macro_rules! app_invoke_handler_desktop { commands::export_error_log, commands::debug_read_cursor_context, commands::accept_pending_correction, + commands::add_learned_vocab, commands::reject_pending_correction, commands::dismiss_vocab_suggestions, commands::copy_text_to_clipboard, @@ -569,6 +570,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::marketplace_logout, $crate::commands::list_vocab, $crate::commands::add_vocab, + $crate::commands::add_learned_vocab, $crate::commands::remove_vocab, $crate::commands::set_vocab_enabled, $crate::commands::update_vocab, diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index a4352035b..f017898bc 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -398,7 +398,12 @@ async fn forward_legacy_event( BackendEventKind::RemoteInputFailed(error) => { let _ = app.emit("remote-input:error", error); } - BackendEventKind::VocabularySuggestionsChanged(suggestions) => { + BackendEventKind::VocabularySuggestionsChanged(_) => { + // Concurrent producers can enqueue an older snapshot after a + // dismissal. Present current Core state, never resurrect that card. + let suggestions = backend.pending_corrections(); + #[cfg(target_os = "android")] + crate::android::edit_observation::show_suggestions(&suggestions); if let Some(coordinator) = app.try_state::>() { coordinator.refresh_vocab_suggestion_presentation(!suggestions.is_empty()); } diff --git a/openless-all/app/src/components/LearnVocabulary.tsx b/openless-all/app/src/components/LearnVocabulary.tsx new file mode 100644 index 000000000..7ab2bad6c --- /dev/null +++ b/openless-all/app/src/components/LearnVocabulary.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { addLearnedVocab } from '../lib/ipc/vocab'; +import { Btn } from '../pages/_atoms'; +import { inputStyle } from '../pages/settings/shared'; + +/** An explicit, cross-platform alternative when a host editor cannot be read. */ +export function LearnVocabulary() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [phrase, setPhrase] = useState(''); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(''); + const save = async () => { + if (busy || !phrase.trim()) return; + setBusy(true); + setMessage(''); + try { + await addLearnedVocab(phrase.trim()); + setPhrase(''); + setOpen(false); + setMessage(t('vocabLearning.saved', '已记住,可在词典中管理')); + } catch (error) { + setMessage(t('vocabLearning.failed', '保存失败:{{error}}', { error: String(error) })); + } finally { + setBusy(false); + } + }; + return ( +
+ {!open ? ( + { + setOpen(true); + setMessage(''); + }} + > + {t('vocabLearning.open', '记住词汇')} + + ) : ( +
{ + if (e.key === 'Enter' && !e.nativeEvent.isComposing) { + e.preventDefault(); + void save(); + } + }} + > + +
+ void save()}> + {t('vocabLearning.confirm', '确认加入词典')} + + { + setOpen(false); + setPhrase(''); + setMessage(''); + }} + > + {t('common.cancel')} + +
+
+ )} + {message && ( +
+ {message} +
+ )} +
+ ); +} diff --git a/openless-all/app/src/components/VocabSuggestionCard.tsx b/openless-all/app/src/components/VocabSuggestionCard.tsx index 03ecb9e09..fd6bdb5df 100644 --- a/openless-all/app/src/components/VocabSuggestionCard.tsx +++ b/openless-all/app/src/components/VocabSuggestionCard.tsx @@ -13,16 +13,9 @@ import { Icon } from './Icon'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { - acceptPendingCorrection, - dismissVocabSuggestions, - rejectPendingCorrection, -} from '../lib/ipc'; +import { acceptPendingCorrection, rejectPendingCorrection } from '../lib/ipc'; import type { PendingCorrection } from '../lib/types'; -/// 卡片自己消失的时间,与后端 `VOCAB_SUGGESTION_TTL_MS` 对齐。 -const TTL_MS = 10_000; - interface VocabSuggestionCardProps { suggestions: PendingCorrection[]; } @@ -33,14 +26,23 @@ export function VocabSuggestionCard({ suggestions }: VocabSuggestionCardProps) { const [resolved, setResolved] = useState>(new Set()); const timerRef = useRef(null); - // 10 秒倒计时。列表一变就重新计时:同一次听写里连着改了几个词会陆续追加进来, - // 不重置的话后来的那条可能刚出现就没了。 + // Each suggestion has a Core deadline; new suggestions do not extend old ones. useEffect(() => { if (suggestions.length === 0) return; if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = window.setTimeout(() => { - void dismissVocabSuggestions(); - }, TTL_MS); + const earliest = Math.min(...suggestions.map((s) => s.expiresAtMs)); + timerRef.current = window.setTimeout( + () => { + const expired = suggestions.filter((s) => s.expiresAtMs <= Date.now()); + setResolved((previous) => new Set([...previous, ...expired.map((s) => s.id)])); + for (const suggestion of suggestions) { + if (suggestion.expiresAtMs <= Date.now()) { + void rejectPendingCorrection(suggestion.id).catch(() => {}); + } + } + }, + Math.max(0, earliest - Date.now()), + ); return () => { if (timerRef.current) clearTimeout(timerRef.current); }; diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index c5cdf0595..a39fac72e 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -844,6 +844,13 @@ export const de: typeof zhCN = { clear: 'Ergebnisse leeren', }, }, + vocabLearning: { + open: 'Wort merken', + label: 'Das richtige Wort eingeben', + confirm: 'Bestätigen und zum Wörterbuch hinzufügen', + saved: 'Gespeichert. Im Wörterbuch verwalten.', + failed: 'Speichern fehlgeschlagen: {{error}}', + }, vocabCard: { title: 'Dieses Wort merken?', accept: 'Merken', @@ -883,7 +890,7 @@ export const de: typeof zhCN = { editEmpty: 'Das Wort darf nicht leer sein.', filter: { all: 'Alle', - auto: 'Automatisch hinzugefügt', + auto: 'Bestätigt', manual: 'Manuell hinzugefügt', }, searchPlaceholder: 'Suchen', @@ -895,7 +902,7 @@ export const de: typeof zhCN = { newWordTemplates: 'Vorlagen', newWordTemplateCount: 'Wörter: {{count}}', newWordAddSelected: 'Auswahl hinzufügen', - learnedSection: 'Automatisch gesammelt ({{count}})', + learnedSection: 'Bestätigt ({{count}})', removeAllLearned: 'Alle entfernen', corrections: { title: 'Korrekturregeln', @@ -1265,11 +1272,14 @@ export const de: typeof zhCN = { 'Anfragen verwenden den Systemproxy, wenn diese Option aktiv ist. Andernfalls werden alle Anfragen direkt gesendet, was bei inländischen Diensten meist schneller ist. Ausländische Dienste wie GitHub-Anmeldung und Updates können dann fehlschlagen. Echtzeit-Sprachstreams und Less Computer sind davon unabhängig.', }, dataStorage: { + vocabularyLearningLabel: 'Aus Korrekturen lernen (experimentell)', + vocabularyLearningDesc: + 'Nach dem Einfügen werden Änderungen im Eingabefeld bis zu 60 Sekunden lokal erkannt. Wörter werden erst nach Bestätigung gespeichert. Der beobachtete Text wird nicht an ein Modell gesendet. Passwortfelder und bekannte sensible Apps sind ausgeschlossen. Die Unterstützung unter Windows und Android hängt vom Editor ab; Android benötigt Bedienungshilfen. Ausschalten beendet die Beobachtung.', title: 'Datenspeicherung', desc: 'Gesprächsverlauf und Kontext, die auf diesem Gerät gespeichert werden.', cursorContextLabel: 'Cursorkontext (experimentell)', cursorContextDesc: - 'Liest beim Überarbeiten den Text rund um den Cursor im aktuellen Dokument, damit das Modell gleich klingende Wörter, Eigennamen und Pronomen unterscheiden kann. Bei Aktivierung wird dieser Text mit der Anfrage an deinen LLM-Dienst gesendet; andernfalls wird nichts gelesen. Passwortfelder, Secure Input, Passwortmanager und Terminals werden nie gelesen. Nur unter macOS.', + 'Text rund um den Cursor zur Überarbeitung an das Modell senden (nur macOS). Diese Einstellung ist vom lokalen Lernen getrennt. Passwortfelder und bekannte sensible Apps sind ausgeschlossen.', }, codingConsole: { title: 'Claude-Konsole', @@ -1472,7 +1482,8 @@ export const de: typeof zhCN = { comboClear: 'Leeren', comboConflict: 'Diese Tastenkombination ist nicht verfügbar', shortcutSaveFailed: 'Tastenkombination konnte nicht gespeichert werden', - mouseSideHint: 'Mausseitentasten Mouse4 / Mouse5 werden als globale Diktier-Hotkeys unter Windows unterstützt', + mouseSideHint: + 'Mausseitentasten Mouse4 / Mouse5 werden als globale Diktier-Hotkeys unter Windows unterstützt', allowNonTsfFallbackLabel: 'Alternative ohne TSF erlauben', allowNonTsfFallbackDesc: 'Windows: Falls das Einfügen über TSF fehlschlägt, wird Unicode-Text dosiert über SendInput eingegeben. Schlägt auch das fehl, wird der Text in die Zwischenablage kopiert.', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 53dc51fd9..1b5800aec 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -832,6 +832,13 @@ export const en: typeof zhCN = { clear: 'Clear results', }, }, + vocabLearning: { + open: 'Remember a word', + label: 'Enter the correct word to remember', + confirm: 'Confirm and add to dictionary', + saved: 'Saved. Manage this word in Dictionary.', + failed: 'Could not save: {{error}}', + }, vocabCard: { title: 'Remember this word?', accept: 'Remember', @@ -869,7 +876,7 @@ export const en: typeof zhCN = { editTitle: 'Edit Word', editSave: 'Save', editEmpty: 'Word cannot be empty.', - filter: { all: 'All', auto: 'Auto-Added', manual: 'Manually Added' }, + filter: { all: 'All', auto: 'Confirmed', manual: 'Manually Added' }, searchPlaceholder: 'Search', searchEmpty: 'No matching words.', newWord: 'New Word', @@ -879,7 +886,7 @@ export const en: typeof zhCN = { newWordTemplates: 'Preset Templates', newWordTemplateCount: '{{count}} words', newWordAddSelected: 'Add Selected', - learnedSection: 'Auto-collected ({{count}})', + learnedSection: 'Confirmed ({{count}})', removeAllLearned: 'Remove all', corrections: { title: 'Correction rules', @@ -1246,8 +1253,11 @@ export const en: typeof zhCN = { title: 'Data storage', desc: 'Conversation history and context kept on this device.', cursorContextLabel: 'Cursor context (experimental)', + vocabularyLearningLabel: 'Learn from corrections (experimental)', + vocabularyLearningDesc: + 'After insertion, observe edits locally for up to 60 seconds and ask before saving a word. Observation text is not sent to a model. Password fields and known sensitive apps are excluded. Windows and Android support depends on the editor; Android requires accessibility. Turning this off stops observation.', cursorContextDesc: - 'While polishing, read the text around your cursor in the document you are writing, so the model can tell homophones, proper nouns and pronouns apart. When on, that text is sent to your configured LLM provider with the request; when off, nothing is read at all. Password fields, Secure Input, password managers and terminals are never read. macOS only.', + 'Send nearby document text with polish requests (macOS only). This switch is separate from local vocabulary learning. Password fields and known sensitive apps are excluded.', }, codingConsole: { title: 'Claude Console', @@ -1443,7 +1453,8 @@ export const en: typeof zhCN = { comboClear: 'Clear', comboConflict: 'This shortcut combination is not available', shortcutSaveFailed: 'Failed to save shortcut', - mouseSideHint: 'Mouse4 / Mouse5 side buttons are supported as global dictation hotkeys on Windows', + mouseSideHint: + 'Mouse4 / Mouse5 side buttons are supported as global dictation hotkeys on Windows', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 6861addde..7fcbe1439 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -841,6 +841,13 @@ export const es: typeof zhCN = { clear: 'Borrar resultados', }, }, + vocabLearning: { + open: 'Recordar una palabra', + label: 'Introduce la palabra correcta', + confirm: 'Confirmar y añadir al diccionario', + saved: 'Guardado. Puedes gestionarla en el diccionario.', + failed: 'No se pudo guardar: {{error}}', + }, vocabCard: { title: '¿Recordar esta palabra?', accept: 'Recordar', @@ -880,7 +887,7 @@ export const es: typeof zhCN = { editEmpty: 'La palabra no puede estar vacía.', filter: { all: 'Todas', - auto: 'Añadidas automáticamente', + auto: 'Confirmadas', manual: 'Añadidas manualmente', }, searchPlaceholder: 'Buscar', @@ -892,7 +899,7 @@ export const es: typeof zhCN = { newWordTemplates: 'Plantillas predefinidas', newWordTemplateCount: '{{count}} palabras', newWordAddSelected: 'Añadir seleccionadas', - learnedSection: 'Recogidas automáticamente ({{count}})', + learnedSection: 'Confirmadas ({{count}})', removeAllLearned: 'Eliminar todas', corrections: { title: 'Reglas de corrección', @@ -1259,11 +1266,14 @@ export const es: typeof zhCN = { 'Al activarlo, las solicitudes usan el proxy del sistema. Al desactivarlo, se conectan directamente, lo que suele reducir la latencia con servicios locales, pero puede impedir el acceso a GitHub y las actualizaciones en algunas regiones. No afecta a los flujos de voz en tiempo real ni a Less Computer.', }, dataStorage: { + vocabularyLearningLabel: 'Aprender de las correcciones (experimental)', + vocabularyLearningDesc: + 'Tras insertar texto, detecta cambios en el campo localmente durante un máximo de 60 segundos y pide confirmación antes de guardar palabras. El texto observado no se envía al modelo. Se excluyen contraseñas y aplicaciones sensibles conocidas. En Windows y Android depende del editor; Android requiere accesibilidad. Al desactivarlo se detiene la observación.', title: 'Almacenamiento de datos', desc: 'Historial de conversaciones y contexto guardados en este dispositivo.', cursorContextLabel: 'Contexto del cursor (experimental)', cursorContextDesc: - 'Al mejorar el texto, lee lo que rodea al cursor en el documento para distinguir homófonos, nombres propios y pronombres. Si se activa, ese texto se envía al proveedor LLM configurado junto con la solicitud. Si se desactiva, no se lee nada. Nunca se leen campos de contraseña, Entrada Segura, gestores de contraseñas ni terminales. Solo macOS.', + 'Envía el texto cercano al cursor al modelo para pulirlo (solo macOS). Es independiente del aprendizaje local. Se excluyen contraseñas y aplicaciones sensibles conocidas.', }, codingConsole: { title: 'Consola de Claude', @@ -1464,7 +1474,8 @@ export const es: typeof zhCN = { comboClear: 'Borrar', comboConflict: 'Esta combinación de teclas no está disponible', shortcutSaveFailed: 'No se pudo guardar el atajo', - mouseSideHint: 'Los botones laterales Mouse4 / Mouse5 se admiten como atajos globales de dictado en Windows', + mouseSideHint: + 'Los botones laterales Mouse4 / Mouse5 se admiten como atajos globales de dictado en Windows', allowNonTsfFallbackLabel: 'Permitir alternativa sin TSF', allowNonTsfFallbackDesc: 'Windows: si falla la inserción TSF, usa SendInput Unicode con pausas. Si también falla, copia el texto al portapapeles.', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 8c58c9bb0..a384a770a 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -856,6 +856,13 @@ export const fr: typeof zhCN = { clear: 'Effacer les résultats', }, }, + vocabLearning: { + open: 'Mémoriser un mot', + label: 'Saisissez le mot correct à mémoriser', + confirm: 'Confirmer et ajouter au dictionnaire', + saved: 'Enregistré. Gérez ce mot dans le dictionnaire.', + failed: 'Échec de l’enregistrement : {{error}}', + }, vocabCard: { title: 'Mémoriser ce mot ?', accept: 'Mémoriser', @@ -895,7 +902,7 @@ export const fr: typeof zhCN = { editEmpty: 'Le mot ne peut pas être vide.', filter: { all: 'Tous', - auto: 'Ajoutés automatiquement', + auto: 'Confirmés', manual: 'Ajoutés manuellement', }, searchPlaceholder: 'Rechercher', @@ -907,7 +914,7 @@ export const fr: typeof zhCN = { newWordTemplates: 'Modèles prédéfinis', newWordTemplateCount: '{{count}} mots', newWordAddSelected: 'Ajouter la sélection', - learnedSection: 'Collectés automatiquement ({{count}})', + learnedSection: 'Confirmés ({{count}})', removeAllLearned: 'Tout supprimer', corrections: { title: 'Règles de correction', @@ -1280,11 +1287,14 @@ export const fr: typeof zhCN = { 'Les requêtes suivent le proxy système si cette option est activée. Sinon, elles se connectent directement, ce qui réduit souvent la latence des services locaux, mais peut empêcher l’accès à GitHub ou aux mises à jour dans certaines régions. Les flux vocaux en temps réel et Less Computer ne sont pas concernés.', }, dataStorage: { + vocabularyLearningLabel: 'Apprendre des corrections (expérimental)', + vocabularyLearningDesc: + 'Après insertion, les modifications du champ sont détectées localement pendant 60 secondes maximum. Chaque mot nécessite une confirmation. Le texte observé n’est pas envoyé au modèle. Les champs de mot de passe et les applications sensibles connues sont exclus. Sous Windows et Android, la prise en charge dépend de l’éditeur ; Android nécessite le service d’accessibilité. Désactiver arrête l’observation.', title: 'Stockage des données', desc: 'Historique des conversations et contexte conservés sur cet appareil.', cursorContextLabel: 'Contexte du curseur (expérimental)', cursorContextDesc: - 'Lors de l’amélioration du texte, lit le contenu autour du curseur dans votre document pour distinguer les homophones, noms propres et pronoms. S’il est activé, ce texte accompagne la requête au fournisseur LLM configuré. Sinon, rien n’est lu. Les champs de mot de passe, la saisie sécurisée, les gestionnaires de mots de passe et les terminaux sont toujours exclus. macOS uniquement.', + 'Envoyer le texte autour du curseur au modèle pour la reformulation (macOS uniquement). Ce réglage est indépendant de l’apprentissage local. Les champs de mot de passe et les applications sensibles connues sont exclus.', }, codingConsole: { title: 'Console Claude', @@ -1486,7 +1496,8 @@ export const fr: typeof zhCN = { comboClear: 'Effacer', comboConflict: 'Cette combinaison n’est pas disponible', shortcutSaveFailed: 'Impossible d’enregistrer le raccourci', - mouseSideHint: 'Les boutons latéraux Mouse4 / Mouse5 sont pris en charge comme raccourcis de dictée globaux sous Windows', + mouseSideHint: + 'Les boutons latéraux Mouse4 / Mouse5 sont pris en charge comme raccourcis de dictée globaux sous Windows', allowNonTsfFallbackLabel: 'Autoriser une solution de repli sans TSF', allowNonTsfFallbackDesc: 'Windows : si l’insertion TSF échoue, utilise SendInput Unicode avec des pauses. En cas de nouvel échec, copie le texte dans le presse-papiers.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 0e631dba4..8c1c33b50 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -822,6 +822,13 @@ export const ja: typeof zhCN = { clear: '結果を消去', }, }, + vocabLearning: { + open: '単語を覚える', + label: '覚えたい正しい単語を入力', + confirm: '確認して辞書に追加', + saved: '保存しました。辞書で管理できます。', + failed: '保存できませんでした:{{error}}', + }, vocabCard: { title: 'この語を覚えますか?', accept: '覚える', @@ -860,7 +867,7 @@ export const ja: typeof zhCN = { editTitle: '単語を編集', editSave: '保存', editEmpty: '単語を入力してください。', - filter: { all: 'すべて', auto: '自動追加', manual: '手動追加' }, + filter: { all: 'すべて', auto: '確認して追加', manual: '手動追加' }, searchPlaceholder: '検索', searchEmpty: '一致する単語がありません。', newWord: '新語', @@ -870,7 +877,7 @@ export const ja: typeof zhCN = { newWordTemplates: 'プリセットテンプレート', newWordTemplateCount: '{{count}} 語', newWordAddSelected: '選択を追加', - learnedSection: '自動収集({{count}})', + learnedSection: '確認して追加({{count}})', removeAllLearned: 'すべて削除', corrections: { title: '補正ルール', @@ -1231,11 +1238,14 @@ export const ja: typeof zhCN = { 'オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。', }, dataStorage: { + vocabularyLearningLabel: '修正から単語を学習(実験)', + vocabularyLearningDesc: + '挿入後、入力欄の修正を端末内で最大60秒間検出し、確認後に辞書へ追加します。検出した文章はモデルに送信しません。パスワード欄と既知の機密アプリは除外します。Windows・Androidではエディターによって対応が異なり、Androidはユーザー補助が必要です。オフにすると検出を停止します。', title: 'データ保存', desc: 'この端末に保存される会話履歴とコンテキスト。', cursorContextLabel: 'カーソル文脈(実験的)', cursorContextDesc: - '推敲時に、いま書いている文書のカーソル周辺の原文を読み取り、同音語・固有名詞・代名詞の書き分けをモデルが判断できるようにします。オンにすると、そのテキストがリクエストとともに設定中の LLM プロバイダへ送信されます。オフのときは一文字も読み取りません。パスワード入力欄、Secure Input、パスワード管理アプリ、ターミナルは常に読み取りません。macOS のみ。', + '推敲時にカーソル付近の文章をモデルへ送信します(macOSのみ)。端末内の単語学習とは独立した設定です。パスワード欄と既知の機密アプリは除外します。', }, codingConsole: { title: 'Claude コンソール', @@ -1433,7 +1443,8 @@ export const ja: typeof zhCN = { comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', shortcutSaveFailed: 'ショートカットの保存に失敗しました', - mouseSideHint: 'マウスサイドボタン Mouse4 / Mouse5 は Windows でグローバルなディクテーションホットキーとして使えます', + mouseSideHint: + 'マウスサイドボタン Mouse4 / Mouse5 は Windows でグローバルなディクテーションホットキーとして使えます', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index addac7f98..f5c911387 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -816,6 +816,13 @@ export const ko: typeof zhCN = { clear: '결과 지우기', }, }, + vocabLearning: { + open: '단어 기억하기', + label: '기억할 올바른 단어를 입력하세요', + confirm: '확인하고 사전에 추가', + saved: '저장했습니다. 사전에서 관리할 수 있습니다.', + failed: '저장 실패: {{error}}', + }, vocabCard: { title: '이 단어를 기억할까요?', accept: '기억하기', @@ -853,7 +860,7 @@ export const ko: typeof zhCN = { editTitle: '단어 편집', editSave: '저장', editEmpty: '단어를 입력하세요.', - filter: { all: '전체', auto: '자동 추가', manual: '수동 추가' }, + filter: { all: '전체', auto: '확인 후 수집', manual: '수동 추가' }, searchPlaceholder: '검색', searchEmpty: '일치하는 단어가 없습니다.', newWord: '새 단어', @@ -863,7 +870,7 @@ export const ko: typeof zhCN = { newWordTemplates: '프리셋 템플릿', newWordTemplateCount: '{{count}}개 단어', newWordAddSelected: '선택 추가', - learnedSection: '자동 수집 ({{count}})', + learnedSection: '확인 후 수집 ({{count}})', removeAllLearned: '모두 삭제', corrections: { title: '교정 규칙', @@ -1223,11 +1230,14 @@ export const ko: typeof zhCN = { '켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.', }, dataStorage: { + vocabularyLearningLabel: '수정한 단어 학습 (실험)', + vocabularyLearningDesc: + '입력 후 최대 60초 동안 기기에서 해당 입력란의 수정을 감지하고 확인 후 사전에 추가합니다. 관찰한 텍스트는 모델에 보내지 않습니다. 비밀번호 입력란과 알려진 민감한 앱은 제외됩니다. Windows 및 Android는 편집기에 따라 지원이 다르며 Android는 접근성 서비스가 필요합니다. 끄면 관찰을 중지합니다.', title: '데이터 저장', desc: '이 기기에 보관되는 대화 기록과 컨텍스트.', cursorContextLabel: '커서 문맥 (실험적)', cursorContextDesc: - '다듬을 때 작성 중인 문서에서 커서 주변 원문을 읽어, 동음이의어·고유명사·대명사를 모델이 구분할 수 있게 합니다. 켜면 해당 텍스트가 요청과 함께 설정된 LLM 제공자로 전송됩니다. 끄면 한 글자도 읽지 않습니다. 비밀번호 입력란, Secure Input, 비밀번호 관리자, 터미널은 항상 읽지 않습니다. macOS 전용.', + '다듬기 요청 시 커서 주변 텍스트를 모델에 보냅니다(macOS 전용). 기기 내 단어 학습과 별도 설정입니다. 비밀번호 입력란과 알려진 민감한 앱은 제외됩니다.', }, codingConsole: { title: 'Claude 콘솔', @@ -1421,7 +1431,8 @@ export const ko: typeof zhCN = { comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', shortcutSaveFailed: '단축키를 저장하지 못했습니다', - mouseSideHint: '마우스 측면 버튼 Mouse4 / Mouse5는 Windows에서 전역 받아쓰기 단축키로 지원됩니다', + mouseSideHint: + '마우스 측면 버튼 Mouse4 / Mouse5는 Windows에서 전역 받아쓰기 단축키로 지원됩니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index c402f18a5..ed555052f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -794,6 +794,13 @@ export const zhCN = { clear: '清除结果', }, }, + vocabLearning: { + open: '记住词汇', + label: '输入要记住的正确词汇', + confirm: '确认加入词典', + saved: '已记住,可在词典中管理', + failed: '保存失败:{{error}}', + }, vocabCard: { title: '要记住这个词吗?', accept: '记住', @@ -830,7 +837,7 @@ export const zhCN = { editTitle: '编辑词汇', editSave: '保存', editEmpty: '词条不能为空。', - filter: { all: '所有', auto: '自动添加', manual: '手动添加' }, + filter: { all: '所有', auto: '确认收集', manual: '手动添加' }, searchPlaceholder: '搜索', searchEmpty: '没有匹配的词条。', newWord: '新词', @@ -840,7 +847,7 @@ export const zhCN = { newWordTemplates: '预设模板', newWordTemplateCount: '{{count}} 词', newWordAddSelected: '添加所选', - learnedSection: '自动收集({{count}})', + learnedSection: '确认收集({{count}})', removeAllLearned: '全部删除', corrections: { title: '纠正规则', @@ -1194,8 +1201,11 @@ export const zhCN = { title: '数据存储', desc: '本机保留的历史会话与对话上下文。', cursorContextLabel: '光标上下文(实验)', + vocabularyLearningLabel: '手改学词(实验)', + vocabularyLearningDesc: + '插入后在本机观察当前输入框内的修改,最长 60 秒,确认后才加入词典。观察文本不会发给模型;排除密码框和已知敏感应用。Windows、Android 的支持取决于编辑器;Android 需开启无障碍服务。关闭后停止观察。', cursorContextDesc: - '润色时读取你正在写的那篇文档中光标附近的原文,帮模型判断同音词、专名和代词该怎么写。开启后这段文字会随请求发送给你配置的 LLM 服务商;关闭时一个字都不读。密码输入框、Secure Input、密码管理器与终端始终不读。仅 macOS。', + '润色时将光标附近文本发给模型(仅 macOS)。此开关与本地手改学词独立;排除密码框和已知敏感应用。', }, codingConsole: { title: 'Claude 控制台', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 647b8ea32..337c24afa 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -794,6 +794,13 @@ export const zhTW: typeof zhCN = { clear: '清除結果', }, }, + vocabLearning: { + open: '記住詞彙', + label: '輸入要記住的正確詞彙', + confirm: '確認加入詞典', + saved: '已記住,可在詞典中管理', + failed: '儲存失敗:{{error}}', + }, vocabCard: { title: '要記住這個詞嗎?', accept: '記住', @@ -831,7 +838,7 @@ export const zhTW: typeof zhCN = { editEmpty: '詞條不能為空。', filter: { all: '所有', - auto: '自動新增', + auto: '確認收集', manual: '手動新增', }, searchPlaceholder: '搜尋', @@ -843,7 +850,7 @@ export const zhTW: typeof zhCN = { newWordTemplates: '預設範本', newWordTemplateCount: '{{count}} 詞', newWordAddSelected: '新增所選', - learnedSection: '自動收集({{count}})', + learnedSection: '確認收集({{count}})', removeAllLearned: '全部刪除', corrections: { title: '糾正規則', @@ -1191,11 +1198,14 @@ export const zhTW: typeof zhCN = { '開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。', }, dataStorage: { + vocabularyLearningLabel: '手改學詞(實驗)', + vocabularyLearningDesc: + '插入後在本機觀察目前輸入框內的修改,最長 60 秒,確認後才加入詞典。觀察文字不會傳給模型;排除密碼欄位和已知敏感應用程式。Windows、Android 支援取決於編輯器;Android 需開啟無障礙服務。關閉後停止觀察。', title: '資料儲存', desc: '本機保留的歷史會話與對話上下文。', cursorContextLabel: '遊標上下文(實驗)', cursorContextDesc: - '潤稿時讀取你正在寫的那篇檔案中游標附近的原文,幫模型判斷同音詞、專有名詞與代詞該怎麼寫。開啟後這段文字會隨請求送給你設定的 LLM 服務商;關閉時一個字都不讀。密碼輸入框、Secure Input、密碼管理器與終端機始終不讀。僅 macOS。', + '潤色時將游標附近文字傳給模型(僅 macOS)。此開關與本機手改學詞獨立;排除密碼欄位和已知敏感應用程式。', }, codingConsole: { title: 'Claude 主控臺', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 8bafbb012..a516eb034 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -124,6 +124,7 @@ export let mockSettings: UserPreferences = { streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, cursorContextEnabled: false, + vocabularyLearningEnabled: false, showOverviewActivityHeatmap: true, stackedRowLayout: false, conservativeLayout: false, diff --git a/openless-all/app/src/lib/ipc/vocab.ts b/openless-all/app/src/lib/ipc/vocab.ts index 113e5e684..fc9ebfbd1 100644 --- a/openless-all/app/src/lib/ipc/vocab.ts +++ b/openless-all/app/src/lib/ipc/vocab.ts @@ -6,6 +6,21 @@ export function listVocab(): Promise { return invokeOrMock('list_vocab', undefined, () => mockVocab.map((entry) => ({ ...entry }))); } +export function addLearnedVocab(phrase: string): Promise { + return invokeOrMock('add_learned_vocab', { phrase }, () => { + if (!mockVocab.some((entry) => entry.phrase === phrase.trim())) { + mockVocab.unshift({ + id: crypto.randomUUID(), + phrase: phrase.trim(), + note: '从手改中自动收集', + enabled: true, + hits: 0, + createdAt: new Date().toISOString(), + }); + } + }); +} + export function addVocab(phrase: string, note?: string): Promise { return invokeOrMock('add_vocab', { phrase, note }, () => { const entry = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 56fa25a93..91c23a348 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -123,6 +123,7 @@ export interface HostDocumentReadResult { /** 一条等待用户确认的纠正建议(Tier2)。后端只存在内存里,重启即空——建议本身是 * 易逝的,用户下次犯同样的错会再产生一条。 */ export interface PendingCorrection { + expiresAtMs: number; id: string; pattern: string; replacement: string; @@ -510,6 +511,7 @@ export interface UserPreferences { * 默认 false —— 开启后每次听写都会读取前台 app 的正文并把其中一段发给 LLM 服务商。 * 仅 macOS 有实现;密码框 / Secure Input / 密码管理器 / 终端一律硬拦。 */ cursorContextEnabled: boolean; + vocabularyLearningEnabled: boolean; /** 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,活动计数照常记录。 */ showOverviewActivityHeatmap: boolean; /** 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。默认 false。 */ diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index b129c4485..fef465ebf 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; +import { LearnVocabulary } from '../components/LearnVocabulary'; import { Tooltip } from '../components/Tooltip'; import { AssistantMarkdown } from '../components/chat/markdown'; import { detectOS } from '../components/WindowChrome'; @@ -337,10 +338,7 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } const file = new File([bin], `openless-recording-${item.id}.wav`, { type: 'audio/wav', }); - if ( - !navigator.share || - (navigator.canShare && !navigator.canShare({ files: [file] })) - ) { + if (!navigator.share || (navigator.canShare && !navigator.canShare({ files: [file] }))) { await onExportAudio(); return; } @@ -553,7 +551,9 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } : t('history.empty', { trigger: prefs ? formatComboLabel( - (quickNotesOnly ? prefs.quickNoteHotkey : prefs.dictationHotkey) ?? { + (quickNotesOnly + ? prefs.quickNoteHotkey + : prefs.dictationHotkey) ?? { primary: '', modifiers: [], }, @@ -706,7 +706,9 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } + {showRawTranscript && ( @@ -941,21 +944,21 @@ export function History({ quickNotesOnly = false }: { quickNotesOnly?: boolean } {repolishOpen && (item.rawTranscript.trim() || quickNotesOnly) && item.errorCode !== 'qaSession' && ( - setRepolishOpen(false)} - persistOnApply={quickNotesOnly} - onApplied={(updated) => - setItems((prev) => - prev.map((entry) => (entry.id === updated.id ? updated : entry)), - ) - } - key={`repolish-${item.id}`} - /> - )} + setRepolishOpen(false)} + persistOnApply={quickNotesOnly} + onApplied={(updated) => + setItems((prev) => + prev.map((entry) => (entry.id === updated.id ? updated : entry)), + ) + } + key={`repolish-${item.id}`} + /> + )} ) : (
)} {onApply && text.trim() && ( - + {applying ? t('quickNote.applying', 'Applying…') : applyLabel} )} @@ -1578,7 +1576,7 @@ function isUserCancelled(message: string): boolean { } /** 当 session.hasAudioRecording 为 true 时渲染:由详情操作菜单触发加载,拿到字节后切换为 - * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 + * 原生 audio controls。Blob URL 在组件 unmount 时 revoke,避免泄漏。 * `onMissing` 在后端返回 'recording not found'(wav 已被 prune)时触发,让父组件 * 把按钮永久隐藏,避免用户继续点击得到同样错误。 */ function AudioRecordingPlayer({ diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index d7f332960..2df59f1de 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -93,6 +93,18 @@ export function DataStorageSection() { /> )} + + void savePrefs({ ...prefs, vocabularyLearningEnabled: next })} + /> + ); } From 3d57d416845b825d1313602e3406f289236a43a8 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 30 Sep 2026 02:55:30 +0800 Subject: [PATCH 2/5] =?UTF-8?q?test(vocab):=20=E8=A1=A5=E9=BD=90=E5=BB=BA?= =?UTF-8?q?=E8=AE=AE=E8=BF=87=E6=9C=9F=E6=97=B6=E9=97=B4=E7=9A=84=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E6=A0=B7=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/contract/backend-2.0.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index 25507cdfd..b3d8adc28 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -102,7 +102,7 @@ "qa_state": {"sequence": 1, "sessionId": null, "kind": {"type": "qa_state", "payload": {"kind": "idle"}}}, "remote_input_status_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "remote_input_status_changed", "payload": {"running": false}}}, "remote_input_failed": {"sequence": 1, "sessionId": null, "kind": {"type": "remote_input_failed", "payload": {"reason": "unavailable", "port": 18989}}}, - "vocabulary_suggestions_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "vocabulary_suggestions_changed", "payload": [{"id": "1", "pattern": "old", "replacement": "new"}]}} + "vocabulary_suggestions_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "vocabulary_suggestions_changed", "payload": [{"id": "1", "expiresAtMs": 2000000000000, "pattern": "old", "replacement": "new"}]}} } }, "lessComputerVoice": { From 848389c92220c94318f6a532889ba4f0dfb65ef0 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 30 Sep 2026 14:17:36 +0800 Subject: [PATCH 3/5] =?UTF-8?q?feat(vocab):=20=E5=B0=86=E6=89=8B=E6=94=B9?= =?UTF-8?q?=E5=AD=A6=E8=AF=8D=E7=A7=BB=E5=85=A5=E5=AE=9E=E9=AA=8C=E9=A1=B5?= =?UTF-8?q?=E5=B9=B6=E5=BC=80=E6=94=BE=E5=AD=A6=E4=B9=A0=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- README.zh.md | 2 +- .../kotlin/OpenLessAccessibilityService.kt | 15 ++- .../app/android/kotlin/OpenLessNative.kt | 1 + .../kotlin/OpenLessVocabularyReceiver.kt | 9 +- .../app/crates/openless-core/src/api.rs | 100 ++++++++++++++++- .../src/cloud_sync_e2ee_documents/registry.rs | 7 ++ .../openless-core/src/host_document/diff.rs | 48 +++++++-- .../openless-core/src/host_document/mod.rs | 3 +- .../app/crates/openless-core/src/ports.rs | 4 + .../crates/openless-core/src/shared_types.rs | 35 ++++++ .../src-tauri/src/android/edit_observation.rs | 43 ++++++-- .../app/src-tauri/src/core_adapters.rs | 7 +- .../app/src-tauri/src/host_document/macos.rs | 7 +- .../app/src-tauri/src/host_document/mod.rs | 17 +-- .../src-tauri/src/host_document/windows.rs | 10 +- openless-all/app/src/i18n/de.ts | 29 ++++- openless-all/app/src/i18n/en.ts | 29 ++++- openless-all/app/src/i18n/es.ts | 29 ++++- openless-all/app/src/i18n/fr.ts | 29 ++++- openless-all/app/src/i18n/ja.ts | 27 ++++- openless-all/app/src/i18n/ko.ts | 27 ++++- openless-all/app/src/i18n/zh-CN.ts | 25 ++++- openless-all/app/src/i18n/zh-TW.ts | 25 ++++- openless-all/app/src/lib/ipc/mock-data.ts | 1 + openless-all/app/src/lib/ipc/vocab.ts | 2 +- openless-all/app/src/lib/types.ts | 5 + .../src/pages/settings/DataStorageSection.tsx | 12 --- .../settings/VocabularyLearningSection.tsx | 102 ++++++++++++++++++ .../app/src/pages/settings/navigation.test.ts | 13 ++- .../app/src/pages/settings/navigation.ts | 3 + openless-all/app/src/pages/settings/tabs.tsx | 2 + 32 files changed, 584 insertions(+), 86 deletions(-) create mode 100644 openless-all/app/src/pages/settings/VocabularyLearningSection.tsx diff --git a/README.md b/README.md index 0e4083429..eaad3f553 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ The dictionary handles your proper nouns, product names, names of people, and ne - Manually adding the correct spelling, a category, and notes. You do not need to maintain misspellings or context hints. - Enabled entries are sent to the ASR provider that supports hotwords (Volcengine `context.hotwords`, StepFun `hotwords`, Whisper-compatible `prompt` — except ZenMux, whose JSON protocol does not carry `prompt`/`hotwords`, Bailian vocabulary ID) so they are recognized correctly during transcription. iFlytek realtime ASR has no request-level hotword parameter — configure personalized hotwords in the iFlytek console instead. - Entries are also injected into the polish prompt: the model decides per sentence whether to substitute. If "Cloud" clearly refers to the AI product `Claude` in context, it is corrected; if it genuinely means cloud computing, it is left as is. -- **Learn from corrections (experimental).** Enable this separately in Settings → Privacy → Data storage; it defaults to off and is not enabled by cursor context or cloud sync. On macOS, Windows and Android, supported editors can be observed for up to 60 seconds after insertion. Each suggestion needs confirmation within about 10 seconds before entering the dictionary. Android requires accessibility. When observation is unavailable, use **Remember a word** in history details; Android IME result editing also offers an unchecked dictionary option. Every path requires explicit confirmation and does not create global replacement rules. +- **Learn from corrections (experimental).** Open Settings → Experiments & extensions → Learn from corrections to enable it and configure observation duration (10–60 seconds, default 60), suggestion duration (5–60 seconds, default 10), and maximum automatic phrase length (2–32 characters, default 12). It defaults to off and is not enabled by cursor context or cloud sync. On macOS, Windows and Android, supported editors can be observed after insertion. Suggestions require confirmation before expiry to enter the dictionary. Changing parameters stops the current observation and clears pending suggestions; new values apply to the next dictation. Android requires accessibility. When observation is unavailable, use **Remember a word** in history details; Android IME result editing also offers an unchecked dictionary option. Every path requires explicit confirmation and does not create global replacement rules. - **Entries that earn their keep get priority.** The hotword budget sent to ASR providers is finite (a few hundred characters). Entries are ranked by hit count, with a few reserved seats for words you just added by hand, so the terms you actually use keep their place instead of being pushed out by whatever you added most recently. ### Cursor context (opt-in, macOS) diff --git a/README.zh.md b/README.zh.md index 6bfad563a..3cbe54d1f 100644 --- a/README.zh.md +++ b/README.zh.md @@ -378,7 +378,7 @@ OpenLess 的润色模型只重塑文本。它不回答问题、不执行任务 - 手动添加正确拼写、分类与备注。你无需维护错误拼写或上下文提示。 - 启用的条目作为 Volcengine ASR 的 `context.hotwords` 发送,以便在转写时被正确识别。 - 条目同样注入润色提示词:模型逐句判断是否替换。如果“Cloud”在上下文中明显指 AI 产品 `Claude`,就会被纠正;如果它确实指云计算,则保持原样。 -- **手改学词(实验)。** 在「设置 → 隐私 → 数据存储」单独开启,默认关闭,不跟随光标上下文或云同步授权。macOS、Windows 和 Android 可在支持的编辑器中观察插入后的修改,最长 60 秒;候选需在约 10 秒内逐条确认才加入词典。Android 需要无障碍服务。无法观察时,可在历史详情点击「记住词汇」手动输入正确词;Android 输入法编辑结果也提供默认不勾选的加入词典选项。所有入口均需明确确认,不自动创建全局替换规则。 +- **手改学词(实验)。** 在「设置 → 实验与扩展 → 手改学词」进入独立配置页,设置观察时长(10–60 秒,默认 60)、建议保留时长(5–60 秒,默认 10)及自动建议最大词长(2–32 个字符,默认 12)。功能默认关闭,不跟随光标上下文或云同步授权。macOS、Windows 和 Android 可在支持的编辑器中观察插入后的修改,候选需在有效期内逐条确认才加入词典。修改参数会结束当前观察并清空待确认建议,下次听写生效。Android 需要无障碍服务。无法观察时,可在历史详情点击「记住词汇」手动输入正确词;Android 输入法编辑结果也提供默认不勾选的加入词典选项。所有入口均需明确确认,不自动创建全局替换规则。 - **真正在用的词优先。** 发给 ASR 的热词预算是有限的(几百字符)。条目按命中次数排序,并给刚手动添加的词留几个保底席位——这样你天天在用的那些词不会被「最近刚加的」挤出去。 ### 光标上下文(需手动开启,仅 macOS) diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index 5580509c5..c0014d9e6 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -70,12 +70,15 @@ class OpenLessAccessibilityService : AccessibilityService() { if (generation == 0L || vocabularyGeneration == generation) return stopVocabularyObservation() vocabularyGeneration = generation - requestVocabulary("active", Bundle().apply { putLong("generation", generation) }, true) { active, _ -> - if (active) captureVocabularyNode(generation) else stopVocabularyObservation() + requestVocabulary("active", Bundle().apply { putLong("generation", generation) }, true) { active, response -> + val deadline = response?.getLong("deadline") ?: 0L + if (active && deadline > android.os.SystemClock.elapsedRealtime()) { + captureVocabularyNode(generation, deadline) + } else stopVocabularyObservation() } } - private fun captureVocabularyNode(generation: Long) { + private fun captureVocabularyNode(generation: Long, deadline: Long) { val root = rootInActiveWindow ?: run { stopVocabularyObservation(); return } val node = try { root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) } finally { root.recycle() } if (node == null) { stopVocabularyObservation(); return } @@ -89,9 +92,11 @@ class OpenLessAccessibilityService : AccessibilityService() { vocabularyGeneration = generation vocabularyNode = node vocabularyStarted = android.os.SystemClock.elapsedRealtime() - vocabularyDeadline = vocabularyStarted + 60_000L + vocabularyDeadline = deadline + val remaining = deadline - vocabularyStarted + if (remaining <= 0L) { stopVocabularyObservation(); return } mainHandler.post(vocabularyRead) - mainHandler.postDelayed(vocabularyTimeout, 60_000L) + mainHandler.postDelayed(vocabularyTimeout, remaining) } private fun stopVocabularyObservation() { diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index c6841051f..1c09a1f22 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -29,6 +29,7 @@ object OpenLessNative { @JvmStatic external fun nativeCancelDictation() @JvmStatic external fun nativeObserveVocabularyText(generation: Long, text: String): Boolean @JvmStatic external fun nativeVocabularyObservationActive(generation: Long): Boolean + @JvmStatic external fun nativeVocabularyObservationRemainingMs(generation: Long): Long @JvmStatic external fun nativeCurrentVocabularyObservation(): Long @JvmStatic external fun nativeStopVocabularyObservation(generation: Long) @JvmStatic external fun nativePendingVocabularySuggestions(): String diff --git a/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt b/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt index c6c36aaa4..337c6c015 100644 --- a/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt +++ b/openless-all/app/android/kotlin/OpenLessVocabularyReceiver.kt @@ -25,7 +25,14 @@ class OpenLessVocabularyReceiver : BroadcastReceiver() { OpenLessNative.nativeStopVocabularyObservation(intent.getLongExtra("generation", 0)) true } - "active" -> OpenLessNative.nativeVocabularyObservationActive(intent.getLongExtra("generation", 0)) + "active" -> { + // elapsedRealtime is shared across processes. Timestamp before the + // native query so IPC latency can only shorten the observation. + val now = android.os.SystemClock.elapsedRealtime() + val remaining = OpenLessNative.nativeVocabularyObservationRemainingMs(intent.getLongExtra("generation", 0)) + response.putLong("deadline", now + remaining) + remaining > 0L + } "observe" -> { val text = intent.getStringExtra("text") text != null && text.length <= 20_000 && OpenLessNative.nativeObserveVocabularyText(intent.getLongExtra("generation", 0), text) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 0a8346f60..4507a4de8 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2122,6 +2122,7 @@ struct HistoryProviderAttribution { } struct CoreEditObservationSink { + settings: crate::shared_types::VocabularyLearningSettings, expected_generation: u64, generation: Arc, typed_text: String, @@ -2130,6 +2131,10 @@ struct CoreEditObservationSink { } impl EditObservationSink for CoreEditObservationSink { + fn observation_duration(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.settings.observation_seconds as u64) + } + fn publish(&self, edit: crate::host_document::EditPair) -> bool { // Dropping the native watcher is asynchronous on macOS: a queued AX // callback may still arrive after the next session starts. The Core @@ -2140,7 +2145,10 @@ impl EditObservationSink for CoreEditObservationSink { if !crate::host_document::edit_is_within_typed_text(&edit, &self.typed_text) { return false; } - let Some(rule) = crate::host_document::learned_rule(&edit) else { + let Some(rule) = crate::host_document::learned_rule_with_max_chars( + &edit, + self.settings.max_phrase_chars as usize, + ) else { return false; }; if let Err(error) = queue_pending_correction_state( @@ -2149,6 +2157,7 @@ impl EditObservationSink for CoreEditObservationSink { rule.pattern, rule.replacement, Some((&self.generation, self.expected_generation)), + self.settings.suggestion_seconds, ) { log::warn!("failed to queue observed correction: {error}"); } @@ -2162,6 +2171,7 @@ fn queue_pending_correction_state( pattern: String, replacement: String, generation: Option<(&AtomicU64, u64)>, + suggestion_seconds: u32, ) -> Result, BackendError> { if pattern.trim().is_empty() || replacement.trim().is_empty() { return Err(BackendError::new( @@ -2188,7 +2198,7 @@ fn queue_pending_correction_state( } let suggestion = PendingCorrection { id: uuid::Uuid::new_v4().to_string(), - expires_at_ms: now + crate::shared_types::VOCAB_SUGGESTION_TTL_MS as i64, + expires_at_ms: now + i64::from(suggestion_seconds.clamp(5, 60)) * 1000, pattern, replacement, }; @@ -4429,6 +4439,8 @@ impl OpenLessBackend { }); } } + preferences.vocabulary_learning_settings = + preferences.vocabulary_learning_settings.normalized(); let mut previous = self.preferences.get(); crate::sync_dictation_hotkey_legacy_fields(&mut previous); crate::sync_dictation_hotkey_legacy_fields(&mut preferences); @@ -4509,7 +4521,9 @@ impl OpenLessBackend { .reset(); } - if previous.vocabulary_learning_enabled && !preferences.vocabulary_learning_enabled { + if (previous.vocabulary_learning_enabled && !preferences.vocabulary_learning_enabled) + || previous.vocabulary_learning_settings != preferences.vocabulary_learning_settings + { self.disarm_edit_observation(); self.dismiss_pending_corrections(); } @@ -4973,6 +4987,9 @@ impl OpenLessBackend { pattern, replacement, None, + self.get_preferences() + .vocabulary_learning_settings + .suggestion_seconds, ) } @@ -5017,6 +5034,10 @@ impl OpenLessBackend { } let expected_generation = self.edit_observation_generation.load(Ordering::Acquire); let sink = Arc::new(CoreEditObservationSink { + settings: self + .get_preferences() + .vocabulary_learning_settings + .normalized(), expected_generation, generation: Arc::clone(&self.edit_observation_generation), typed_text: typed_text.to_string(), @@ -6709,6 +6730,76 @@ mod tests { fn assert_send_sync() {} + #[test] + fn vocabulary_learning_settings_migrate_normalize_and_roundtrip() { + let old: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(!old.vocabulary_learning_enabled); + assert_eq!(old.vocabulary_learning_settings, Default::default()); + let prefs: UserPreferences = serde_json::from_value(serde_json::json!({ + "vocabularyLearningSettings": { + "observationSeconds": 0, "suggestionSeconds": 999, "maxPhraseChars": 99 + } + })) + .unwrap(); + assert_eq!(prefs.vocabulary_learning_settings.observation_seconds, 10); + assert_eq!(prefs.vocabulary_learning_settings.suggestion_seconds, 60); + assert_eq!(prefs.vocabulary_learning_settings.max_phrase_chars, 32); + let restored: UserPreferences = + serde_json::from_value(serde_json::to_value(&prefs).unwrap()).unwrap(); + assert_eq!( + restored.vocabulary_learning_settings, + prefs.vocabulary_learning_settings + ); + } + + #[test] + fn vocabulary_learning_config_change_invalidates_pending_and_stale_sink() { + let (backend, _) = backend(); + let mut prefs = backend.get_preferences(); + prefs.vocabulary_learning_enabled = true; + prefs.vocabulary_learning_settings.observation_seconds = 20; + prefs.vocabulary_learning_settings.suggestion_seconds = 30; + prefs.vocabulary_learning_settings.max_phrase_chars = 16; + backend.set_preferences(prefs).unwrap(); + let sink = CoreEditObservationSink { + settings: backend.get_preferences().vocabulary_learning_settings, + expected_generation: backend.edit_observation_generation.load(Ordering::Acquire), + generation: Arc::clone(&backend.edit_observation_generation), + typed_text: "甲".repeat(13), + pending: Arc::clone(&backend.pending_corrections), + events: Arc::clone(&backend.events), + }; + assert_eq!( + sink.observation_duration(), + std::time::Duration::from_secs(20) + ); + let edit = crate::host_document::EditPair { + source: "甲".repeat(13), + target: "乙".repeat(13), + before: String::new(), + after: String::new(), + }; + let before = chrono::Utc::now().timestamp_millis(); + assert!(sink.publish(edit.clone())); + let after = chrono::Utc::now().timestamp_millis(); + let suggestion = backend.pending_corrections().pop().unwrap(); + assert!((before + 30_000..=after + 30_000).contains(&suggestion.expires_at_ms)); + let mut prefs = backend.get_preferences(); + prefs.vocabulary_learning_settings.observation_seconds = 999; + backend.set_preferences(prefs).unwrap(); + assert_eq!( + backend + .get_preferences() + .vocabulary_learning_settings + .observation_seconds, + 60 + ); + assert!(backend.pending_corrections().is_empty()); + assert!(!sink.publish(edit)); + assert!(backend.pending_corrections().is_empty()); + assert!(backend.get_preferences().vocabulary_learning_enabled); + } + #[test] fn vocabulary_learning_expired_suggestions_cannot_be_accepted() { let (backend, _) = backend(); @@ -6756,7 +6847,8 @@ mod tests { &backend.events, "错词".into(), "Codex".into(), - Some((&generation, 1)) + Some((&generation, 1)), + 10 ) .unwrap() .is_none()); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs index 03274dc27..2a8062ed2 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -662,6 +662,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "local_consent_or_secret", }, + PreferenceField { + rust_name: "vocabulary_learning_settings", + key: "vocabularyLearningSettings", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Object, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "show_overview_activity_heatmap", key: "showOverviewActivityHeatmap", diff --git a/openless-all/app/crates/openless-core/src/host_document/diff.rs b/openless-all/app/crates/openless-core/src/host_document/diff.rs index ff37e2a69..63c0003ed 100644 --- a/openless-all/app/crates/openless-core/src/host_document/diff.rs +++ b/openless-all/app/crates/openless-core/src/host_document/diff.rs @@ -56,18 +56,26 @@ pub struct LearnedRule { } pub fn is_vocab_worthy(edit: &EditPair) -> bool { + is_vocab_worthy_with_max_chars(edit, MAX_PHRASE_CHARS) +} + +fn is_vocab_worthy_with_max_chars(edit: &EditPair, max_chars: usize) -> bool { let source = edit.source.trim(); let target = edit.target.trim(); !source.is_empty() && !target.is_empty() && !crosses_boundary(source) && !crosses_boundary(target) - && source.chars().count() <= MAX_PHRASE_CHARS - && target.chars().count() <= MAX_PHRASE_CHARS + && source.chars().count() <= max_chars + && target.chars().count() <= max_chars } pub fn learned_rule(edit: &EditPair) -> Option { - if !is_vocab_worthy(edit) { + learned_rule_with_max_chars(edit, MAX_PHRASE_CHARS) +} + +pub fn learned_rule_with_max_chars(edit: &EditPair, max_chars: usize) -> Option { + if !is_vocab_worthy_with_max_chars(edit, max_chars) { return None; } let before: Vec = edit.before.chars().collect(); @@ -91,10 +99,14 @@ pub fn learned_rule(edit: &EditPair) -> Option { let replacement = format!("{prefix}{}{suffix}", edit.target) .trim() .to_string(); - (!pattern.is_empty() && !replacement.is_empty()).then_some(LearnedRule { - pattern, - replacement, - }) + (!pattern.is_empty() + && !replacement.is_empty() + && pattern.chars().count() <= max_chars + && replacement.chars().count() <= max_chars) + .then_some(LearnedRule { + pattern, + replacement, + }) } fn crosses_boundary(value: &str) -> bool { @@ -114,6 +126,28 @@ pub fn edit_is_within_typed_text(edit: &EditPair, typed_text: &str) -> bool { mod tests { use super::*; + #[test] + fn configurable_phrase_limit_counts_characters_and_expanded_context() { + let mut edit = EditPair { + source: "甲".repeat(13), + target: "乙".repeat(13), + before: String::new(), + after: String::new(), + }; + assert!(learned_rule(&edit).is_none()); + assert!(learned_rule_with_max_chars(&edit, 13).is_some()); + edit.source = "甲".into(); + edit.target = "乙丙".into(); + edit.before = "丁".into(); + assert!(learned_rule_with_max_chars(&edit, 2).is_none()); + assert_eq!( + learned_rule_with_max_chars(&edit, 3).unwrap().replacement, + "丁乙丙" + ); + edit.target = "乙。丙".into(); + assert!(learned_rule_with_max_chars(&edit, 32).is_none()); + } + #[test] fn diff_and_rule_are_char_safe() { let edit = minimal_edit("今天讲大禹", "今天讲大鱼").unwrap(); diff --git a/openless-all/app/crates/openless-core/src/host_document/mod.rs b/openless-all/app/crates/openless-core/src/host_document/mod.rs index 79c4d940a..91cca5200 100644 --- a/openless-all/app/crates/openless-core/src/host_document/mod.rs +++ b/openless-all/app/crates/openless-core/src/host_document/mod.rs @@ -7,7 +7,8 @@ mod observation; mod window; pub use diff::{ - edit_is_within_typed_text, is_vocab_worthy, learned_rule, minimal_edit, EditPair, LearnedRule, + edit_is_within_typed_text, is_vocab_worthy, learned_rule, learned_rule_with_max_chars, + minimal_edit, EditPair, LearnedRule, }; pub use observation::ObservedInsertion; pub use window::{plan_window, utf16_offset_to_char_offset, window_around_cursor, WindowSpan}; diff --git a/openless-all/app/crates/openless-core/src/ports.rs b/openless-all/app/crates/openless-core/src/ports.rs index 05a250737..fcb669339 100644 --- a/openless-all/app/crates/openless-core/src/ports.rs +++ b/openless-all/app/crates/openless-core/src/ports.rs @@ -635,6 +635,10 @@ pub trait TextInsertionSession: Send + Sync { /// document baseline. Keeping that decision here prevents macOS AX code from /// owning vocabulary policy or accepting a report from a stale generation. pub trait EditObservationSink: Send + Sync { + fn observation_duration(&self) -> std::time::Duration { + std::time::Duration::from_secs(60) + } + fn publish(&self, edit: crate::host_document::EditPair) -> bool; } diff --git a/openless-all/app/crates/openless-core/src/shared_types.rs b/openless-all/app/crates/openless-core/src/shared_types.rs index 8a3b7d374..068dda502 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -290,6 +290,35 @@ pub const INSERT_FALLBACK_REASON_INSERT_FAILED: &str = "insertFailed"; /// 换来的好处。 pub const VOCAB_SUGGESTION_TTL_MS: u64 = 10_000; +/// Local-only tuning. Consent and sensitive-field protections remain separate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct VocabularyLearningSettings { + pub observation_seconds: u32, + pub suggestion_seconds: u32, + pub max_phrase_chars: u32, +} + +impl Default for VocabularyLearningSettings { + fn default() -> Self { + Self { + observation_seconds: 60, + suggestion_seconds: 10, + max_phrase_chars: 12, + } + } +} + +impl VocabularyLearningSettings { + pub fn normalized(self) -> Self { + Self { + observation_seconds: self.observation_seconds.clamp(10, 60), + suggestion_seconds: self.suggestion_seconds.clamp(5, 60), + max_phrase_chars: self.max_phrase_chars.clamp(2, 32), + } + } +} + pub use crate::types::{VocabPreset, VocabPresetStore}; pub use crate::style_packs::*; @@ -651,6 +680,8 @@ pub struct UserPreferences { /// Observe corrections locally after insertion; independent of LLM context. #[serde(default)] pub vocabulary_learning_enabled: bool, + #[serde(default)] + pub vocabulary_learning_settings: VocabularyLearningSettings, /// 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片, /// 活动计数照常记录(persistence/activity.rs),再打开时全年数据仍在。 #[serde(default = "default_true")] @@ -960,6 +991,7 @@ struct UserPreferencesWire { cursor_context_enabled: bool, #[serde(default)] vocabulary_learning_enabled: bool, + vocabulary_learning_settings: VocabularyLearningSettings, #[serde(default = "default_true")] show_overview_activity_heatmap: bool, #[serde(default)] @@ -1134,6 +1166,7 @@ impl Default for UserPreferencesWire { streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, cursor_context_enabled: prefs.cursor_context_enabled, vocabulary_learning_enabled: prefs.vocabulary_learning_enabled, + vocabulary_learning_settings: prefs.vocabulary_learning_settings, show_overview_activity_heatmap: prefs.show_overview_activity_heatmap, stacked_row_layout: prefs.stacked_row_layout, conservative_layout: prefs.conservative_layout, @@ -1337,6 +1370,7 @@ impl<'de> Deserialize<'de> for UserPreferences { streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, cursor_context_enabled: wire.cursor_context_enabled, vocabulary_learning_enabled: wire.vocabulary_learning_enabled, + vocabulary_learning_settings: wire.vocabulary_learning_settings.normalized(), show_overview_activity_heatmap: wire.show_overview_activity_heatmap, stacked_row_layout: wire.stacked_row_layout, conservative_layout: wire.conservative_layout, @@ -1688,6 +1722,7 @@ impl Default for UserPreferences { streaming_insert_save_clipboard: true, cursor_context_enabled: false, vocabulary_learning_enabled: false, + vocabulary_learning_settings: VocabularyLearningSettings::default(), show_overview_activity_heatmap: true, stacked_row_layout: false, conservative_layout: false, diff --git a/openless-all/app/src-tauri/src/android/edit_observation.rs b/openless-all/app/src-tauri/src/android/edit_observation.rs index 21a6391e1..99bc6f693 100644 --- a/openless-all/app/src-tauri/src/android/edit_observation.rs +++ b/openless-all/app/src-tauri/src/android/edit_observation.rs @@ -15,18 +15,24 @@ struct Observation { text: String, anchor: Option, started: Instant, + lifetime: Duration, callback: Box bool + Send + Sync>, } static OBSERVATION: Mutex> = Mutex::new(None); static NEXT: AtomicU64 = AtomicU64::new(1); -pub fn arm(text: String, callback: Box bool + Send + Sync>) -> Option { +pub fn arm( + text: String, + lifetime: Duration, + callback: Box bool + Send + Sync>, +) -> Option { let generation = NEXT.fetch_add(1, Ordering::AcqRel); *OBSERVATION.lock().ok()? = Some(Observation { generation, text, anchor: None, started: Instant::now(), + lifetime, callback, }); let result = super::jni::android::with_android_env(|env, context| { @@ -60,7 +66,7 @@ pub fn arm(text: String, callback: Box bool + Send + Sync>) disarm(generation); return; } - tokio::time::sleep(Duration::from_secs(59)).await; + tokio::time::sleep(lifetime.saturating_sub(Duration::from_secs(1))).await; disarm(generation); }); Some(generation) @@ -106,7 +112,7 @@ pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeCurrentVocabul return 0; }; if slot.as_ref().is_some_and(|o| { - o.started.elapsed() >= Duration::from_secs(60) + o.started.elapsed() >= o.lifetime || (o.anchor.is_none() && o.started.elapsed() >= Duration::from_secs(1)) }) { *slot = None; @@ -130,12 +136,35 @@ pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeVocabularyObse generation: jlong, ) -> jboolean { OBSERVATION.lock().ok().is_some_and(|slot| { - slot.as_ref().is_some_and(|o| { - o.generation == generation as u64 && o.started.elapsed() < Duration::from_secs(60) - }) + slot.as_ref() + .is_some_and(|o| o.generation == generation as u64 && o.started.elapsed() < o.lifetime) }) as jboolean } +#[no_mangle] +pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeVocabularyObservationRemainingMs( + _: JNIEnv, + _: JClass, + generation: jlong, +) -> jlong { + OBSERVATION + .lock() + .ok() + .and_then(|slot| { + slot.as_ref() + .filter(|o| o.generation == generation as u64) + .map(|o| { + let elapsed = o.started.elapsed(); + if o.anchor.is_none() && elapsed >= Duration::from_secs(1) { + 0 + } else { + o.lifetime.saturating_sub(elapsed).as_millis() as jlong + } + }) + }) + .unwrap_or(0) +} + #[no_mangle] pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeObserveVocabularyText( mut env: JNIEnv, @@ -153,7 +182,7 @@ pub extern "system" fn Java_com_openless_app_OpenLessNative_nativeObserveVocabul let Some(observation) = slot.as_mut().filter(|o| o.generation == generation as u64) else { return 0; }; - if observation.started.elapsed() >= Duration::from_secs(60) + if observation.started.elapsed() >= observation.lifetime || (observation.anchor.is_none() && observation.started.elapsed() >= Duration::from_secs(1)) { *slot = None; diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 28961c826..73dd92a93 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2770,8 +2770,11 @@ impl EditObservationAdapter for TauriEditObservationAdapter { typed_text: String, sink: Arc, ) -> Result<(), BackendError> { - *self.watcher.lock() = - crate::host_document::watch_for_edits(typed_text, move |edit| sink.publish(edit)); + *self.watcher.lock() = crate::host_document::watch_for_edits( + typed_text, + sink.observation_duration(), + move |edit| sink.publish(edit), + ); Ok(()) } diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index acaa75390..661ea1442 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -29,7 +29,7 @@ use core_foundation::runloop::{ use super::{ evaluate_gate, minimal_edit, plan_window, utf16_offset_to_char_offset, window_around_cursor, - EditPair, GateInputs, ReadOutcome, AX_MESSAGING_TIMEOUT_SECS, EDIT_WATCH_MAX_LIFETIME, + EditPair, GateInputs, ReadOutcome, AX_MESSAGING_TIMEOUT_SECS, }; /// 超过这个 UTF-16 长度就不整篇 `AXValue` 读回来,改走 `AXStringForRange` 只取光标附近。 @@ -1103,6 +1103,7 @@ const EDIT_WATCH_MAX_UTF16: usize = 20_000; /// `spawn_blocking` 好 —— 那个要排 tokio 阻塞池的队,负载高时反而更晚。 pub(super) fn spawn_edit_watcher( typed_text: String, + lifetime: std::time::Duration, on_edit: Box bool + Send + Sync>, ) -> Option> { let stop = Arc::new(AtomicBool::new(false)); @@ -1144,6 +1145,7 @@ pub(super) fn spawn_edit_watcher( pid, bundle_id, thread_stop, + lifetime, ); }); @@ -1239,6 +1241,7 @@ fn run_edit_watch_loop( pid: i32, bundle_id: Option, stop: Arc, + lifetime: std::time::Duration, ) { unsafe { let mut observer: AxObserverRef = std::ptr::null_mut(); @@ -1308,7 +1311,7 @@ fn run_edit_watch_loop( break; } // 60 秒硬上限:过了这么久还在改,多半是在写新东西而不是纠我们插的词。 - if started.elapsed() >= EDIT_WATCH_MAX_LIFETIME { + if started.elapsed() >= lifetime { end_reason = "timeout"; break; } diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index 8dd91769b..d982c094c 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -63,8 +63,6 @@ const READ_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(1200) /// /// 过了一分钟用户还在动这段文字,多半是在继续写新东西而不是纠我们插错的词,再学下去 /// 只会收进噪声。同时这也是「观察器绝不泄漏」的最后一道保险。 -#[cfg(target_os = "macos")] -const EDIT_WATCH_MAX_LIFETIME: std::time::Duration = std::time::Duration::from_secs(60); /// 一次读取的结局。`Ok` 之外的每一种都要能说清「为什么没读到」—— 装机验证时全靠它 /// 判断某个 app 是「被拦了」还是「AX 根本不支持」。 @@ -351,7 +349,11 @@ impl Drop for EditWatcher { /// /// `on_edit` 在观察线程上被调用,可能多次。任何失败都返回 `None` —— 学不到东西是可以 /// 接受的,影响落字不行。 -pub fn watch_for_edits(typed_text: String, on_edit: F) -> Option +pub fn watch_for_edits( + typed_text: String, + lifetime: std::time::Duration, + on_edit: F, +) -> Option where F: Fn(EditPair) -> bool + Send + Sync + 'static, { @@ -360,21 +362,22 @@ where if typed_text.trim().is_empty() { return None; } - let stop = macos::spawn_edit_watcher(typed_text, Box::new(on_edit))?; + let stop = macos::spawn_edit_watcher(typed_text, lifetime, Box::new(on_edit))?; Some(EditWatcher { stop }) } #[cfg(target_os = "windows")] { - windows::spawn_edit_watcher(typed_text, Box::new(on_edit)).map(|stop| EditWatcher { stop }) + windows::spawn_edit_watcher(typed_text, lifetime, Box::new(on_edit)) + .map(|stop| EditWatcher { stop }) } #[cfg(target_os = "android")] { - crate::android::edit_observation::arm(typed_text, Box::new(on_edit)) + crate::android::edit_observation::arm(typed_text, lifetime, Box::new(on_edit)) .map(|generation| EditWatcher { generation }) } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "android")))] { - let _ = (typed_text, on_edit); + let _ = (typed_text, lifetime, on_edit); None } } diff --git a/openless-all/app/src-tauri/src/host_document/windows.rs b/openless-all/app/src-tauri/src/host_document/windows.rs index ea42ec6b6..8acef471f 100644 --- a/openless-all/app/src-tauri/src/host_document/windows.rs +++ b/openless-all/app/src-tauri/src/host_document/windows.rs @@ -94,6 +94,7 @@ pub(crate) fn insert_with_delivery_check( struct Request { text: String, window: isize, + lifetime: Duration, stop: Arc, callback: Callback, } @@ -124,7 +125,11 @@ impl IUIAutomationPropertyChangedEventHandler_Impl for Changed_Impl { } } -pub(super) fn spawn_edit_watcher(text: String, callback: Callback) -> Option> { +pub(super) fn spawn_edit_watcher( + text: String, + lifetime: Duration, + callback: Callback, +) -> Option> { if text.trim().is_empty() { return None; } @@ -153,6 +158,7 @@ pub(super) fn spawn_edit_watcher(text: String, callback: Callback) -> Option Result<()> { // only retain their own dirty flag; no host text or Core sink is accessible. let result = (|| -> Result<()> { let mut changed_at = Some(Instant::now()); - while started.elapsed() < Duration::from_secs(60) && active()? { + while started.elapsed() < request.lifetime && active()? { if dirty.swap(false, Ordering::AcqRel) { changed_at = Some(Instant::now()); } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index a39fac72e..e78e1cabd 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1271,10 +1271,28 @@ export const de: typeof zhCN = { useSystemProxyDesc: 'Anfragen verwenden den Systemproxy, wenn diese Option aktiv ist. Andernfalls werden alle Anfragen direkt gesendet, was bei inländischen Diensten meist schneller ist. Ausländische Dienste wie GitHub-Anmeldung und Updates können dann fehlschlagen. Echtzeit-Sprachstreams und Less Computer sind davon unabhängig.', }, - dataStorage: { - vocabularyLearningLabel: 'Aus Korrekturen lernen (experimentell)', - vocabularyLearningDesc: + vocabularyLearning: { + title: 'Aus Korrekturen lernen', + enabled: 'Lernen aus Korrekturen aktivieren', + observationSeconds: 'Beobachtungsdauer', + observationSecondsHint: + 'Das aktuelle Eingabefeld nach dem Diktat höchstens 10–60 Sekunden beobachten.', + suggestionSeconds: 'Anzeigedauer der Vorschläge', + suggestionSecondsHint: + 'Unbestätigte Vorschläge verfallen nach 5–60 Sekunden ohne Wörterbucheintrag.', + maxPhraseChars: 'Maximale Wortlänge', + maxPhraseCharsHint: + 'Original und Ersatz in automatischen Vorschlägen auf 2–32 Zeichen begrenzen. Manuelle Einträge bleiben unverändert.', + seconds: '{{count}} s', + characters: '{{count}} Zeichen', + changeHint: + 'Änderungen beenden die aktuelle Beobachtung und löschen offene Vorschläge. Sie gelten ab dem nächsten Diktat. Zurücksetzen ändert den Aktivierungsschalter nicht.', + reset: 'Parameter zurücksetzen', + saveError: 'Speichern fehlgeschlagen. Bitte erneut versuchen.', + description: 'Nach dem Einfügen werden Änderungen im Eingabefeld bis zu 60 Sekunden lokal erkannt. Wörter werden erst nach Bestätigung gespeichert. Der beobachtete Text wird nicht an ein Modell gesendet. Passwortfelder und bekannte sensible Apps sind ausgeschlossen. Die Unterstützung unter Windows und Android hängt vom Editor ab; Android benötigt Bedienungshilfen. Ausschalten beendet die Beobachtung.', + }, + dataStorage: { title: 'Datenspeicherung', desc: 'Gesprächsverlauf und Kontext, die auf diesem Gerät gespeichert werden.', cursorContextLabel: 'Cursorkontext (experimentell)', @@ -2335,6 +2353,8 @@ export const de: typeof zhCN = { autoSaveHint: 'Änderungen werden automatisch gespeichert', backToAdvanced: 'Zurück zu Experimente und Erweiterungen', advancedPages: { + vocabularyLearning: + 'Aus manuellen Korrekturen lernen; Beobachtung, Bestätigungszeit und Wortlänge einstellen.', lessComputer: 'Wähle einen Agenten und konfiguriere Modell, Berechtigungen und Arbeitsverzeichnis.', claudeConsole: 'Erkenne Claude Code und prüfe die Ausgabe von Testaufträgen.', @@ -2361,7 +2381,8 @@ export const de: typeof zhCN = { appearance: 'Design Dunkel Hell Sprache Schrift Textgröße Layout Aktivitätsübersicht', privacy: 'Berechtigung Mikrofon Bedienungshilfen Verlauf Aufnahme Speicher Datenschutz Export', - advanced: 'Less Computer Claude Agent Multimodal Omni Diagnose Protokolle Experiment', + advanced: + 'Less Computer Claude Agent Multimodal Omni Diagnose Protokolle Experiment Aus Korrekturen lernen vocabulary learning', about: 'Version Beta Stabil Update Aktualisierung', }, sections: { diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 1b5800aec..80f998676 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1249,13 +1249,31 @@ export const en: typeof zhCN = { useSystemProxyDesc: 'When on, requests follow the system proxy. When off, all requests connect directly (usually lower latency for domestic services), but overseas services such as GitHub sign-in and updates may fail. Realtime voice streams and Less Computer are unaffected.', }, + vocabularyLearning: { + title: 'Learn from corrections', + enabled: 'Enable learning from corrections', + observationSeconds: 'Observation duration', + observationSecondsHint: + 'Maximum time to observe the current field after dictation: 10–60 seconds.', + suggestionSeconds: 'Suggestion duration', + suggestionSecondsHint: + 'Unconfirmed suggestions expire after 5–60 seconds without being added to the vocabulary.', + maxPhraseChars: 'Maximum phrase length', + maxPhraseCharsHint: + 'Limit both original and replacement phrases in automatic suggestions to 2–32 characters. Manual additions are unaffected.', + seconds: '{{count}} s', + characters: '{{count}} characters', + changeHint: + 'Changing parameters stops the current observation and clears pending suggestions. New values apply to the next dictation. Resetting parameters does not change the enable switch.', + reset: 'Reset parameters', + saveError: 'Could not save. Please try again.', + description: + 'After insertion, observe edits locally for up to 60 seconds and ask before saving a word. Observation text is not sent to a model. Password fields and known sensitive apps are excluded. Windows and Android support depends on the editor; Android requires accessibility. Turning this off stops observation.', + }, dataStorage: { title: 'Data storage', desc: 'Conversation history and context kept on this device.', cursorContextLabel: 'Cursor context (experimental)', - vocabularyLearningLabel: 'Learn from corrections (experimental)', - vocabularyLearningDesc: - 'After insertion, observe edits locally for up to 60 seconds and ask before saving a word. Observation text is not sent to a model. Password fields and known sensitive apps are excluded. Windows and Android support depends on the editor; Android requires accessibility. Turning this off stops observation.', cursorContextDesc: 'Send nearby document text with polish requests (macOS only). This switch is separate from local vocabulary learning. Password fields and known sensitive apps are excluded.', }, @@ -2273,6 +2291,8 @@ export const en: typeof zhCN = { autoSaveHint: 'Changes save automatically', backToAdvanced: 'Back to Experiments & extensions', advancedPages: { + vocabularyLearning: + 'Learn from manual corrections after dictation; configure observation, confirmation time and phrase length.', lessComputer: 'Choose an agent and configure its model, permissions, and working directory.', claudeConsole: 'Detect Claude Code and view output from test tasks.', multimodal: 'Manage the experimental multimodal recognition switch.', @@ -2295,7 +2315,8 @@ export const en: typeof zhCN = { services: 'ASR LLM API channel model cloud local offline network proxy marketplace', appearance: 'theme dark light language font text size layout heatmap', privacy: 'permission microphone accessibility history recording storage privacy export', - advanced: 'Less Computer Claude Agent multimodal Omni debug logs experiment', + advanced: + 'Less Computer Claude Agent multimodal Omni debug logs experiment Learn from corrections vocabulary learning', about: 'version Beta stable update upgrade', }, diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 7fcbe1439..f1fe773a1 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1265,10 +1265,28 @@ export const es: typeof zhCN = { useSystemProxyDesc: 'Al activarlo, las solicitudes usan el proxy del sistema. Al desactivarlo, se conectan directamente, lo que suele reducir la latencia con servicios locales, pero puede impedir el acceso a GitHub y las actualizaciones en algunas regiones. No afecta a los flujos de voz en tiempo real ni a Less Computer.', }, - dataStorage: { - vocabularyLearningLabel: 'Aprender de las correcciones (experimental)', - vocabularyLearningDesc: + vocabularyLearning: { + title: 'Aprender de las correcciones', + enabled: 'Activar aprendizaje de correcciones', + observationSeconds: 'Tiempo de observación', + observationSecondsHint: + 'Observar el campo actual durante un máximo de 10–60 segundos después del dictado.', + suggestionSeconds: 'Duración de las sugerencias', + suggestionSecondsHint: + 'Las sugerencias sin confirmar caducan tras 5–60 segundos sin añadirse al diccionario.', + maxPhraseChars: 'Longitud máxima', + maxPhraseCharsHint: + 'Limitar el original y el reemplazo de las sugerencias automáticas a 2–32 caracteres. No afecta a las adiciones manuales.', + seconds: '{{count}} s', + characters: '{{count}} caracteres', + changeHint: + 'Cambiar parámetros detiene la observación y borra las sugerencias pendientes. Se aplican al siguiente dictado. Restablecer parámetros no cambia la activación.', + reset: 'Restablecer parámetros', + saveError: 'No se pudo guardar. Inténtalo de nuevo.', + description: 'Tras insertar texto, detecta cambios en el campo localmente durante un máximo de 60 segundos y pide confirmación antes de guardar palabras. El texto observado no se envía al modelo. Se excluyen contraseñas y aplicaciones sensibles conocidas. En Windows y Android depende del editor; Android requiere accesibilidad. Al desactivarlo se detiene la observación.', + }, + dataStorage: { title: 'Almacenamiento de datos', desc: 'Historial de conversaciones y contexto guardados en este dispositivo.', cursorContextLabel: 'Contexto del cursor (experimental)', @@ -2317,6 +2335,8 @@ export const es: typeof zhCN = { autoSaveHint: 'Los cambios se guardan automáticamente', backToAdvanced: 'Volver a Experimentos y extensiones', advancedPages: { + vocabularyLearning: + 'Aprender de correcciones manuales; configurar observación, confirmación y longitud.', lessComputer: 'Elige un agente y configura su modelo, permisos y directorio de trabajo.', claudeConsole: 'Detecta Claude Code y consulta la salida de las tareas de prueba.', multimodal: 'Administra la activación del reconocimiento multimodal experimental.', @@ -2343,7 +2363,8 @@ export const es: typeof zhCN = { appearance: 'tema oscuro claro idioma fuente texto tamaño diseño mapa actividad', privacy: 'permiso micrófono accesibilidad historial grabación almacenamiento privacidad exportar', - advanced: 'Less Computer Claude agente multimodal Omni depuración registros experimento', + advanced: + 'Less Computer Claude agente multimodal Omni depuración registros experimento Aprender de las correcciones vocabulary learning', about: 'versión Beta estable actualización actualizar', }, sections: { diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index a384a770a..d9c13a3b8 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1286,10 +1286,28 @@ export const fr: typeof zhCN = { useSystemProxyDesc: 'Les requêtes suivent le proxy système si cette option est activée. Sinon, elles se connectent directement, ce qui réduit souvent la latence des services locaux, mais peut empêcher l’accès à GitHub ou aux mises à jour dans certaines régions. Les flux vocaux en temps réel et Less Computer ne sont pas concernés.', }, - dataStorage: { - vocabularyLearningLabel: 'Apprendre des corrections (expérimental)', - vocabularyLearningDesc: + vocabularyLearning: { + title: 'Apprendre des corrections', + enabled: 'Activer l’apprentissage des corrections', + observationSeconds: 'Durée d’observation', + observationSecondsHint: + 'Observer le champ actuel pendant 10 à 60 secondes au maximum après la dictée.', + suggestionSeconds: 'Durée des suggestions', + suggestionSecondsHint: + 'Les suggestions non confirmées expirent après 5 à 60 secondes sans ajout au dictionnaire.', + maxPhraseChars: 'Longueur maximale', + maxPhraseCharsHint: + 'Limiter le texte original et son remplacement à 2–32 caractères dans les suggestions automatiques. Les ajouts manuels ne sont pas concernés.', + seconds: '{{count}} s', + characters: '{{count}} caractères', + changeHint: + 'Modifier les paramètres arrête l’observation et efface les suggestions en attente. Ils s’appliquent à la prochaine dictée. La réinitialisation ne change pas l’activation.', + reset: 'Réinitialiser les paramètres', + saveError: 'Échec de l’enregistrement. Réessayez.', + description: 'Après insertion, les modifications du champ sont détectées localement pendant 60 secondes maximum. Chaque mot nécessite une confirmation. Le texte observé n’est pas envoyé au modèle. Les champs de mot de passe et les applications sensibles connues sont exclus. Sous Windows et Android, la prise en charge dépend de l’éditeur ; Android nécessite le service d’accessibilité. Désactiver arrête l’observation.', + }, + dataStorage: { title: 'Stockage des données', desc: 'Historique des conversations et contexte conservés sur cet appareil.', cursorContextLabel: 'Contexte du curseur (expérimental)', @@ -2350,6 +2368,8 @@ export const fr: typeof zhCN = { autoSaveHint: 'Les modifications sont enregistrées automatiquement', backToAdvanced: 'Retour à Expériences et extensions', advancedPages: { + vocabularyLearning: + 'Apprendre des corrections manuelles ; régler l’observation, la confirmation et la longueur.', lessComputer: 'Choisissez un agent et configurez son modèle, ses autorisations et son répertoire de travail.', claudeConsole: 'Détectez Claude Code et consultez la sortie des tâches de test.', @@ -2379,7 +2399,8 @@ export const fr: typeof zhCN = { appearance: 'thème sombre clair langue police texte taille disposition carte activité', privacy: 'autorisation microphone accessibilité historique enregistrement stockage confidentialité exporter', - advanced: 'Less Computer Claude agent multimodal Omni débogage journaux expérience', + advanced: + 'Less Computer Claude agent multimodal Omni débogage journaux expérience Apprendre des corrections vocabulary learning', about: 'version Beta stable mise à jour actualisation', }, sections: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 8c1c33b50..b70997e0f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1237,10 +1237,26 @@ export const ja: typeof zhCN = { useSystemProxyDesc: 'オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。', }, - dataStorage: { - vocabularyLearningLabel: '修正から単語を学習(実験)', - vocabularyLearningDesc: + vocabularyLearning: { + title: '修正から単語を学習', + enabled: '修正からの学習を有効にする', + observationSeconds: '監視時間', + observationSecondsHint: '音声入力後、現在の入力欄を監視する最大時間(10~60 秒)。', + suggestionSeconds: '候補の表示時間', + suggestionSecondsHint: '未確認の候補は 5~60 秒後に消え、辞書には追加されません。', + maxPhraseChars: '最大文字数', + maxPhraseCharsHint: + '自動候補の元の語と置換後の語を 2~32 文字に制限します。手動追加には影響しません。', + seconds: '{{count}} 秒', + characters: '{{count}} 文字', + changeHint: + '設定を変更すると現在の監視を終了し、未確認の候補を消去します。次の音声入力から適用されます。リセットしても有効化スイッチは変わりません。', + reset: '設定値をリセット', + saveError: '保存できませんでした。再試行してください。', + description: '挿入後、入力欄の修正を端末内で最大60秒間検出し、確認後に辞書へ追加します。検出した文章はモデルに送信しません。パスワード欄と既知の機密アプリは除外します。Windows・Androidではエディターによって対応が異なり、Androidはユーザー補助が必要です。オフにすると検出を停止します。', + }, + dataStorage: { title: 'データ保存', desc: 'この端末に保存される会話履歴とコンテキスト。', cursorContextLabel: 'カーソル文脈(実験的)', @@ -2245,6 +2261,8 @@ export const ja: typeof zhCN = { autoSaveHint: '変更は自動保存されます', backToAdvanced: '実験と拡張に戻る', advancedPages: { + vocabularyLearning: + '音声入力後の手動修正から学習し、監視時間・確認時間・文字数を設定します。', lessComputer: 'Agent を選び、モデル・権限・作業ディレクトリを設定します。', claudeConsole: 'Claude Code を検出し、テストタスクの実行出力を確認します。', multimodal: '実験的なマルチモーダル認識の有効・無効を設定します。', @@ -2265,7 +2283,8 @@ export const ja: typeof zhCN = { services: 'ASR LLM API チャンネル モデル クラウド ローカル ネットワーク プロキシ マーケット', appearance: 'テーマ ダーク ライト 言語 フォント 文字 サイズ レイアウト ヒートマップ', privacy: '権限 マイク アクセシビリティ 履歴 録音 保存 プライバシー エクスポート', - advanced: 'Less Computer Claude Agent マルチモーダル Omni デバッグ ログ 実験', + advanced: + 'Less Computer Claude Agent マルチモーダル Omni デバッグ ログ 実験 修正から単語を学習 vocabulary learning', about: 'バージョン Beta 安定 更新 アップデート', }, diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index f5c911387..9110c5dd1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1229,10 +1229,26 @@ export const ko: typeof zhCN = { useSystemProxyDesc: '켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.', }, - dataStorage: { - vocabularyLearningLabel: '수정한 단어 학습 (실험)', - vocabularyLearningDesc: + vocabularyLearning: { + title: '수정으로 단어 학습', + enabled: '수정 학습 사용', + observationSeconds: '관찰 시간', + observationSecondsHint: '음성 입력 후 현재 입력란을 관찰하는 최대 시간입니다(10~60초).', + suggestionSeconds: '제안 유지 시간', + suggestionSecondsHint: '확인하지 않은 제안은 5~60초 후 사전에 추가되지 않고 사라집니다.', + maxPhraseChars: '최대 글자 수', + maxPhraseCharsHint: + '자동 제안의 원문과 대체 문구를 2~32자로 제한합니다. 수동 추가에는 영향을 주지 않습니다.', + seconds: '{{count}}초', + characters: '{{count}}자', + changeHint: + '설정을 변경하면 현재 관찰을 중지하고 대기 중인 제안을 지웁니다. 다음 음성 입력부터 적용됩니다. 초기화해도 사용 스위치는 바뀌지 않습니다.', + reset: '매개변수 초기화', + saveError: '저장하지 못했습니다. 다시 시도하세요.', + description: '입력 후 최대 60초 동안 기기에서 해당 입력란의 수정을 감지하고 확인 후 사전에 추가합니다. 관찰한 텍스트는 모델에 보내지 않습니다. 비밀번호 입력란과 알려진 민감한 앱은 제외됩니다. Windows 및 Android는 편집기에 따라 지원이 다르며 Android는 접근성 서비스가 필요합니다. 끄면 관찰을 중지합니다.', + }, + dataStorage: { title: '데이터 저장', desc: '이 기기에 보관되는 대화 기록과 컨텍스트.', cursorContextLabel: '커서 문맥 (실험적)', @@ -2226,6 +2242,8 @@ export const ko: typeof zhCN = { autoSaveHint: '변경 사항이 자동 저장됩니다', backToAdvanced: '실험 및 확장으로 돌아가기', advancedPages: { + vocabularyLearning: + '음성 입력 후 수동 수정으로 학습하고 관찰 시간, 확인 시간, 글자 수를 설정합니다.', lessComputer: 'Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.', claudeConsole: 'Claude Code를 감지하고 테스트 작업의 실행 출력을 확인합니다.', multimodal: '실험적 멀티모달 인식 기능의 사용 여부를 설정합니다.', @@ -2246,7 +2264,8 @@ export const ko: typeof zhCN = { services: 'ASR LLM API 채널 모델 클라우드 로컬 네트워크 프록시 마켓', appearance: '테마 다크 라이트 언어 글꼴 글자 크기 배치 레이아웃 히트맵', privacy: '권한 마이크 접근성 기록 녹음 저장 개인정보 내보내기', - advanced: 'Less Computer Claude Agent 멀티모달 Omni 디버그 로그 실험', + advanced: + 'Less Computer Claude Agent 멀티모달 Omni 디버그 로그 실험 수정으로 단어 학습 vocabulary learning', about: '버전 Beta 안정 업데이트 업그레이드', }, diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ed555052f..20c220e08 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1197,13 +1197,28 @@ export const zhCN = { useSystemProxyDesc: '开启时请求跟随系统代理;关闭后所有网络请求直连(国内服务延迟通常更低),GitHub 登录、更新等境外服务可能连不上。实时语音流与 Less Computer 不受此开关影响。', }, + vocabularyLearning: { + title: '手改学词', + enabled: '启用手改学词', + observationSeconds: '观察时长', + observationSecondsHint: '听写落字后观察当前输入框的最长时间,10–60 秒。', + suggestionSeconds: '建议保留时长', + suggestionSecondsHint: '未确认的建议会在 5–60 秒后消失,不加入词典。', + maxPhraseChars: '最大词长', + maxPhraseCharsHint: '自动建议中原词和替换词的长度上限,2–32 个字符;不影响手动加词。', + seconds: '{{count}} 秒', + characters: '{{count}} 个字符', + changeHint: + '更改参数会结束当前观察并清除待确认建议,下次听写生效。恢复参数不会改变启用开关。', + reset: '恢复默认参数', + saveError: '保存失败,请重试。', + description: + '插入后在本机观察当前输入框内的修改,最长 60 秒,确认后才加入词典。观察文本不会发给模型;排除密码框和已知敏感应用。Windows、Android 的支持取决于编辑器;Android 需开启无障碍服务。关闭后停止观察。', + }, dataStorage: { title: '数据存储', desc: '本机保留的历史会话与对话上下文。', cursorContextLabel: '光标上下文(实验)', - vocabularyLearningLabel: '手改学词(实验)', - vocabularyLearningDesc: - '插入后在本机观察当前输入框内的修改,最长 60 秒,确认后才加入词典。观察文本不会发给模型;排除密码框和已知敏感应用。Windows、Android 的支持取决于编辑器;Android 需开启无障碍服务。关闭后停止观察。', cursorContextDesc: '润色时将光标附近文本发给模型(仅 macOS)。此开关与本地手改学词独立;排除密码框和已知敏感应用。', }, @@ -2149,6 +2164,7 @@ export const zhCN = { autoSaveHint: '修改后自动保存', backToAdvanced: '返回实验与扩展', advancedPages: { + vocabularyLearning: '从听写后的手动改词中学习,配置观察时长、确认时间和词长。', lessComputer: '选择 Agent,配置模型、权限与工作目录。', claudeConsole: '检测 Claude Code,并查看测试任务的运行输出。', multimodal: '管理多模态识别的实验性开关。', @@ -2169,7 +2185,8 @@ export const zhCN = { services: 'ASR LLM API 渠道 模型 云 本地 网络 代理 市场', appearance: '主题 深色 浅色 暗色 语言 字号 排版 布局 热力图', privacy: '权限 麦克风 辅助功能 历史 录音 存储 隐私 导出', - advanced: 'Less Computer Claude Agent 多模态 Omni 调试 日志 实验', + advanced: + 'Less Computer Claude Agent 多模态 Omni 调试 日志 实验 手改学词 vocabulary learning', about: '版本 Beta 稳定 更新 升级', }, diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 337c24afa..61c9d9751 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1197,10 +1197,25 @@ export const zhTW: typeof zhCN = { useSystemProxyDesc: '開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。', }, - dataStorage: { - vocabularyLearningLabel: '手改學詞(實驗)', - vocabularyLearningDesc: + vocabularyLearning: { + title: '手改學詞', + enabled: '啟用手改學詞', + observationSeconds: '觀察時長', + observationSecondsHint: '聽寫輸入後觀察目前輸入框的最長時間,10–60 秒。', + suggestionSeconds: '建議保留時長', + suggestionSecondsHint: '未確認的建議會在 5–60 秒後消失,不加入詞典。', + maxPhraseChars: '最大詞長', + maxPhraseCharsHint: '自動建議中原詞和替換詞的長度上限,2–32 個字元;不影響手動加詞。', + seconds: '{{count}} 秒', + characters: '{{count}} 個字元', + changeHint: + '變更參數會結束目前觀察並清除待確認建議,下次聽寫生效。恢復參數不會改變啟用開關。', + reset: '恢復預設參數', + saveError: '儲存失敗,請重試。', + description: '插入後在本機觀察目前輸入框內的修改,最長 60 秒,確認後才加入詞典。觀察文字不會傳給模型;排除密碼欄位和已知敏感應用程式。Windows、Android 支援取決於編輯器;Android 需開啟無障礙服務。關閉後停止觀察。', + }, + dataStorage: { title: '資料儲存', desc: '本機保留的歷史會話與對話上下文。', cursorContextLabel: '遊標上下文(實驗)', @@ -2149,6 +2164,7 @@ export const zhTW: typeof zhCN = { autoSaveHint: '修改後自動儲存', backToAdvanced: '返回實驗與擴充', advancedPages: { + vocabularyLearning: '從聽寫後的手動改詞中學習,設定觀察時長、確認時間和詞長。', lessComputer: '選擇 Agent,設定模型、權限與工作目錄。', claudeConsole: '偵測 Claude Code,並查看測試工作的執行輸出。', multimodal: '管理多模態辨識的實驗性開關。', @@ -2169,7 +2185,8 @@ export const zhTW: typeof zhCN = { services: 'ASR LLM API 管道 模型 雲端 本機 網路 代理 市場', appearance: '主題 深色 淺色 暗色 語言 字體 排版 版面 熱圖', privacy: '權限 麥克風 輔助功能 歷史 錄音 儲存 隱私 匯出', - advanced: 'Less Computer Claude Agent 多模態 Omni 除錯 日誌 實驗', + advanced: + 'Less Computer Claude Agent 多模態 Omni 除錯 日誌 實驗 手改學詞 vocabulary learning', about: '版本 Beta 穩定 更新 升級', }, sections: { diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index a516eb034..145b9aec9 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -125,6 +125,7 @@ export let mockSettings: UserPreferences = { streamingInsertSaveClipboard: true, cursorContextEnabled: false, vocabularyLearningEnabled: false, + vocabularyLearningSettings: { observationSeconds: 60, suggestionSeconds: 10, maxPhraseChars: 12 }, showOverviewActivityHeatmap: true, stackedRowLayout: false, conservativeLayout: false, diff --git a/openless-all/app/src/lib/ipc/vocab.ts b/openless-all/app/src/lib/ipc/vocab.ts index fc9ebfbd1..6666dbfb4 100644 --- a/openless-all/app/src/lib/ipc/vocab.ts +++ b/openless-all/app/src/lib/ipc/vocab.ts @@ -83,7 +83,7 @@ export function rejectPendingCorrection(id: string): Promise { return invokeOrMock('reject_pending_correction', { id }, () => undefined); } -/** 卡片 10 秒到期,或新一轮听写开始。 */ +/** 卡片按配置到期,或新一轮听写开始。 */ export function dismissVocabSuggestions(): Promise { return invokeOrMock('dismiss_vocab_suggestions', undefined, () => undefined); } diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 91c23a348..c15f95db2 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -512,6 +512,11 @@ export interface UserPreferences { * 仅 macOS 有实现;密码框 / Secure Input / 密码管理器 / 终端一律硬拦。 */ cursorContextEnabled: boolean; vocabularyLearningEnabled: boolean; + vocabularyLearningSettings: { + observationSeconds: number; + suggestionSeconds: number; + maxPhraseChars: number; + }; /** 概览页是否显示「年度活动」热力图卡。默认 true;关闭只隐藏卡片,活动计数照常记录。 */ showOverviewActivityHeatmap: boolean; /** 易读布局:小屏或大字号时强制同行控件换行,避免横向溢出。默认 false。 */ diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index 2df59f1de..d7f332960 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -93,18 +93,6 @@ export function DataStorageSection() { /> )} - - void savePrefs({ ...prefs, vocabularyLearningEnabled: next })} - /> - ); } diff --git a/openless-all/app/src/pages/settings/VocabularyLearningSection.tsx b/openless-all/app/src/pages/settings/VocabularyLearningSection.tsx new file mode 100644 index 000000000..8742dfe5c --- /dev/null +++ b/openless-all/app/src/pages/settings/VocabularyLearningSection.tsx @@ -0,0 +1,102 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SelectLite } from '../../components/ui/SelectLite'; +import type { UserPreferences } from '../../lib/types'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; +import { btnGhostStyle, ExperimentalSectionTitle, SettingRow, Toggle } from './shared'; + +const defaults = { observationSeconds: 60, suggestionSeconds: 10, maxPhraseChars: 12 }; +const parameters = [ + { key: 'observationSeconds', min: 10, max: 60, unit: 'seconds' }, + { key: 'suggestionSeconds', min: 5, max: 60, unit: 'seconds' }, + { key: 'maxPhraseChars', min: 2, max: 32, unit: 'characters' }, +] as const; + +export function VocabularyLearningSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const saving = useRef(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + const save = async (update: (current: UserPreferences) => UserPreferences) => { + if (saving.current) return; + saving.current = true; + setBusy(true); + setError(false); + try { + await updatePrefs(update); + } catch { + setError(true); + } finally { + saving.current = false; + setBusy(false); + } + }; + if (!prefs) return {t('common.loading')}; + const settings = prefs.vocabularyLearningSettings ?? defaults; + return ( + + + {t('settings.vocabularyLearning.title')} + +

+ {t('settings.vocabularyLearning.description')} +

+ + + void save((current) => ({ ...current, vocabularyLearningEnabled: enabled })) + } + /> + + {parameters.map(({ key, min, max, unit }) => ( + + { + const count = min + index; + return { + value: String(count), + label: t(`settings.vocabularyLearning.${unit}`, { count }), + }; + })} + onChange={(value) => + void save((current) => ({ + ...current, + vocabularyLearningSettings: { + ...(current.vocabularyLearningSettings ?? defaults), + [key]: Number(value), + }, + })) + } + /> + + ))} +

+ {t('settings.vocabularyLearning.changeHint')} +

+ + {error &&

{t('settings.vocabularyLearning.saveError')}

} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts index bf4babe4c..ea1699133 100644 --- a/openless-all/app/src/pages/settings/navigation.test.ts +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -80,11 +80,22 @@ assert.equal( ); assert.deepEqual( visibleAdvancedPages('android', 'android').map((item) => item.id), - ['multimodal', 'debug'], + ['multimodal', 'vocabularyLearning', 'debug'], 'Android keeps its experimental switch and diagnostics without desktop agents', ); console.log('settings navigation tests passed'); +assert.equal( + visibleAdvancedPages('desktop', 'linux').some((item) => item.id === 'vocabularyLearning'), + false, + 'unsupported native observers do not expose a learning configuration page', +); +assert.equal( + visibleAdvancedPages('desktop', 'mac').some((item) => item.id === 'vocabularyLearning'), + true, + 'macOS retains its native correction observer settings', +); + const omniViews = availableServiceViews(true, true, true); assert.deepEqual( omniViews, diff --git a/openless-all/app/src/pages/settings/navigation.ts b/openless-all/app/src/pages/settings/navigation.ts index 57ccbf807..64b6d1129 100644 --- a/openless-all/app/src/pages/settings/navigation.ts +++ b/openless-all/app/src/pages/settings/navigation.ts @@ -8,6 +8,7 @@ export const ADVANCED_PAGES = [ { id: 'lessComputer', icon: 'mac', titleKey: 'settings.codingAgent.title' }, { id: 'claudeConsole', icon: 'chevLR', titleKey: 'settings.codingConsole.title' }, { id: 'multimodal', icon: 'sparkle', titleKey: 'settings.advanced.multimodalPipelineTitle' }, + { id: 'vocabularyLearning', icon: 'sparkle', titleKey: 'settings.vocabularyLearning.title' }, { id: 'debug', icon: 'bolt', titleKey: 'settings.debug.title' }, ] as const; @@ -18,6 +19,8 @@ export function visibleAdvancedPages(platform: PlatformKind | undefined, os: OS) return ADVANCED_PAGES.filter((page) => { if (page.id === 'lessComputer') return platform === 'desktop' && (os === 'mac' || os === 'win'); if (page.id === 'claudeConsole') return platform === 'desktop' && os === 'mac'; + if (page.id === 'vocabularyLearning') + return platform === 'android' || (platform === 'desktop' && (os === 'win' || os === 'mac')); if (page.id === 'debug') return platform === 'desktop' || platform === 'android'; return true; }); diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index a5f07a256..466bc8a80 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -19,6 +19,7 @@ import { CloudSyncSection } from './CloudSyncSection'; import { LocalModelsSection } from './models/LocalModelsSection'; import { LocalModelsNavContext } from './models/modelsNav'; import { DebugToolsSection } from './DebugToolsSection'; +import { VocabularyLearningSection } from './VocabularyLearningSection'; import { MultimodalPipelineSection } from './MultimodalPipelineSection'; import { CodingAgentSection } from './CodingAgentSection'; import { ClaudeConsoleSection } from './ClaudeConsoleSection'; @@ -284,6 +285,7 @@ export function AdvancedTab({ {item.id === 'lessComputer' && } {item.id === 'claudeConsole' && } {item.id === 'multimodal' && } + {item.id === 'vocabularyLearning' && } {item.id === 'debug' && } ))} From 31a2f24b4328baf92c249eed8b141c3d934d9db1 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 30 Sep 2026 14:19:37 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test(vocab):=20=E9=80=9A=E8=BF=87=E6=AD=A3?= =?UTF-8?q?=E5=BC=8F=E8=AE=BE=E7=BD=AE=E4=BA=8B=E5=8A=A1=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=AD=A6=E4=B9=A0=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/crates/openless-core/src/api.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 4507a4de8..e7231614b 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -6760,7 +6760,13 @@ mod tests { prefs.vocabulary_learning_settings.observation_seconds = 20; prefs.vocabulary_learning_settings.suggestion_seconds = 30; prefs.vocabulary_learning_settings.max_phrase_chars = 16; - backend.set_preferences(prefs).unwrap(); + backend + .update_settings( + prefs, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); let sink = CoreEditObservationSink { settings: backend.get_preferences().vocabulary_learning_settings, expected_generation: backend.edit_observation_generation.load(Ordering::Acquire), @@ -6786,7 +6792,13 @@ mod tests { assert!((before + 30_000..=after + 30_000).contains(&suggestion.expires_at_ms)); let mut prefs = backend.get_preferences(); prefs.vocabulary_learning_settings.observation_seconds = 999; - backend.set_preferences(prefs).unwrap(); + backend + .update_settings( + prefs, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); assert_eq!( backend .get_preferences() From 147bde25cd22a6d337d4068cb8f5409f90bba8f3 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 30 Sep 2026 14:25:37 +0800 Subject: [PATCH 5/5] =?UTF-8?q?test(vocab):=20=E4=BD=BF=E7=94=A8=E5=90=88?= =?UTF-8?q?=E6=B3=95=E5=B9=B3=E5=8F=B0=E7=B1=BB=E5=9E=8B=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E5=AE=9E=E9=AA=8C=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src/pages/settings/navigation.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src/pages/settings/navigation.test.ts b/openless-all/app/src/pages/settings/navigation.test.ts index ea1699133..bbb849210 100644 --- a/openless-all/app/src/pages/settings/navigation.test.ts +++ b/openless-all/app/src/pages/settings/navigation.test.ts @@ -86,9 +86,9 @@ assert.deepEqual( console.log('settings navigation tests passed'); assert.equal( - visibleAdvancedPages('desktop', 'linux').some((item) => item.id === 'vocabularyLearning'), + visibleAdvancedPages('mobile', 'mac').some((item) => item.id === 'vocabularyLearning'), false, - 'unsupported native observers do not expose a learning configuration page', + 'generic mobile hosts without a native observer do not expose learning configuration', ); assert.equal( visibleAdvancedPages('desktop', 'mac').some((item) => item.id === 'vocabularyLearning'),