diff --git a/docs/awareness-follow-up.md b/docs/awareness-follow-up.md index 0fece176f..3a9dec2bf 100644 --- a/docs/awareness-follow-up.md +++ b/docs/awareness-follow-up.md @@ -1,6 +1,6 @@ # Awareness follow-up -The desktop Awareness page currently groups cursor context and opt-in correction suggestions. It does not yet implement application-awareness controls, expression learning, or the complete correction system requested by the user. +The desktop Awareness page groups cursor context, opt-in correction suggestions, and an initial opt-in expression-learning implementation. The initial implementation learns only a small set of global formatting directions, stores abstract evidence locally, and applies stable directions to future dictation prompts. It does not yet implement application-awareness controls, per-app/category profiles, a user blacklist, or the complete correction system requested by the user. ## Reference and evidence @@ -11,7 +11,7 @@ The reviewed implementation separates app/control context, cursor context, corre ## Required implementation - Define independently controlled, opt-in learning and capture boundaries; keep secure fields, sensitive apps, uncertain target changes and blacklist entries excluded. Describe when contextual text may be sent to the configured model. -- Extend bounded, locally observed edits to distinguish content changes, lexical corrections, formatting-only edits and ambiguous cases. Do not learn facts, sensitive values or uncertain target edits as style. -- Persist only bounded abstract expression evidence; define migration, corruption handling, retention, clear/reset semantics and concurrency. Require repeated, consistent evidence across days before applying a profile, with clear app/category/global precedence and decay. Ensure style directives cannot override facts, explicit corrections or user instructions. +- The initial implementation accepts only uniquely anchored formatting edits, rejects sensitive or content-changing observations, and stores bounded text-free global evidence. Opt-out stops observation and new-session application; users can clear the profile. Mixed edits and richer classification remain unsupported. +- Extend the profile to app/category scopes with explicit precedence and decay, plus a user-maintained exclusion list. Validate reset and opt-out races across all hosts, and ensure form directives cannot override facts, explicit corrections or user instructions. - Separate correction suggestion detection from style learning; retain explicit user confirmation for learned vocabulary. Cover conflicting candidates and repeated sessions without treating one edit as a stable habit. - Integrate the controls into the single Awareness page and exercise opt-out, clearing, app exclusion, restart and prompt behavior across supported hosts. Add deterministic classification, persistence, safety, stale-observer and native integration tests before enabling the feature. diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 14d05219a..1af8a63f5 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -1746,6 +1746,7 @@ pub struct BackendRepositories { pub preferences: Arc, pub history: Arc, pub activity: Arc, + pub expression_profile: crate::expression_observation::SharedExpressionProfile, pub vocabulary: Arc, pub correction_rules: Arc, pub style_packs: Arc, @@ -1771,6 +1772,9 @@ impl BackendRepositories { activity: Arc::new( ActivityStore::at_data_dir(data_dir).unwrap_or_else(|_| ActivityStore::in_memory()), ), + expression_profile: crate::expression_observation::open_shared_expression_profile( + data_dir.join("expression-profile.json"), + ), vocabulary: Arc::new(DictionaryStore::at_data_dir(data_dir)), correction_rules: Arc::new(CorrectionRuleStore::at_data_dir(data_dir)), style_packs, @@ -2614,6 +2618,7 @@ pub struct OpenLessBackend { history: Arc, history_revision: Arc, activity: Arc, + expression_profile: crate::expression_observation::SharedExpressionProfile, style_packs: Arc, style_pack_revision: Arc, preferences: Arc, @@ -2675,6 +2680,57 @@ struct CoreEditObservationSink { events: Arc, } +struct CombinedEditObservationSink { + vocabulary: Option, + expression_profile: Option>, + expression_generation: Option, + typed_text: String, + session_id: SessionId, + clock: Arc, + generation: Arc, + expected_generation: u64, + settings_gate: Arc>, + duration: std::time::Duration, +} + +impl EditObservationSink for CombinedEditObservationSink { + fn observation_duration(&self) -> std::time::Duration { + self.duration + } + + fn publish(&self, edit: crate::host_document::EditPair) -> bool { + use chrono::Datelike; + if self.generation.load(Ordering::Acquire) != self.expected_generation { + return false; + } + let signal = self.expression_profile.as_ref().and_then(|_| { + crate::expression_observation::signal_from_observed_edit(&self.typed_text, &edit).ok() + }); + let mut recorded = false; + if let (Some(profile), Some(profile_generation), Some(signal)) = + (&self.expression_profile, self.expression_generation, signal) + { + if let Ok(_guard) = self.settings_gate.try_lock() { + if self.generation.load(Ordering::Acquire) == self.expected_generation { + match profile.record_if_generation( + self.session_id.as_uuid(), + self.clock.today_local().num_days_from_ce(), + signal, + profile_generation, + ) { + Ok(saved) => recorded = saved, + Err(error) => log::warn!("failed to save expression evidence: {error}"), + } + } + } + } + self.vocabulary + .as_ref() + .is_some_and(|sink| sink.publish(edit)) + || recorded + } +} + impl EditObservationSink for CoreEditObservationSink { fn observation_duration(&self) -> std::time::Duration { std::time::Duration::from_secs(self.settings.observation_seconds as u64) @@ -2923,6 +2979,7 @@ impl OpenLessBackend { preferences: Arc::new(PreferencesStore::in_memory()), history: Arc::new(HistoryStore::at_path(std::path::PathBuf::new())), activity: Arc::new(ActivityStore::in_memory()), + expression_profile: Arc::new(Mutex::new(None)), vocabulary: Arc::new(DictionaryStore::at_path(std::path::PathBuf::new())), correction_rules: Arc::new(CorrectionRuleStore::at_path(std::path::PathBuf::new())), style_packs: Arc::new(StylePackStore::in_memory()), @@ -3365,6 +3422,7 @@ impl OpenLessBackend { history: repositories.history, history_revision, activity: repositories.activity, + expression_profile: repositories.expression_profile, style_packs: repositories.style_packs, style_pack_revision, preferences: repositories.preferences, @@ -4061,6 +4119,7 @@ impl OpenLessBackend { preferences: Arc::clone(&self.preferences), history: Arc::clone(&self.history), activity: Arc::clone(&self.activity), + expression_profile: Arc::clone(&self.expression_profile), vocabulary: Arc::clone(&self.vocabulary), correction_rules: Arc::clone(&self.correction_rules), style_packs: Arc::clone(&self.style_packs), @@ -6815,6 +6874,16 @@ impl OpenLessBackend { .reset(); } + if previous.expression_learning_enabled && !preferences.expression_learning_enabled { + if let Ok(profile) = self.expression_profile.lock() { + if let Some(profile) = profile.as_ref() { + if let Err(error) = profile.invalidate_observers() { + log::warn!("failed to invalidate expression observers: {error}"); + } + } + } + self.disarm_edit_observation(); + } if (previous.vocabulary_learning_enabled && !preferences.vocabulary_learning_enabled) || previous.vocabulary_learning_settings != preferences.vocabulary_learning_settings { @@ -7283,6 +7352,38 @@ impl OpenLessBackend { pending.clone() } + pub fn expression_profile_directions( + &self, + ) -> Result, BackendError> { + let profile = self.expression_profile.lock().map_err(|_| { + BackendError::new(BackendErrorCode::Internal, "expression profile lock unavailable") + })?.clone(); + profile + .ok_or_else(|| BackendError::new(BackendErrorCode::Persistence, "expression profile is unavailable"))? + .stable_directions() + } + + pub fn clear_expression_profile(&self) -> Result<(), BackendError> { + self.ensure_runtime_ready()?; + let _guard = self.settings_write_gate.lock().map_err(|_| { + BackendError::new(BackendErrorCode::Internal, "settings lock unavailable") + })?; + self.disarm_edit_observation(); + let mut profile = self.expression_profile.lock().map_err(|_| { + BackendError::new(BackendErrorCode::Internal, "expression profile lock unavailable") + })?; + if let Some(existing) = profile.as_ref() { + if existing.clear().is_ok() { + return Ok(()); + } + } + let store = crate::expression_observation::ExpressionProfileStore::reset( + self.config.data_dir.join("expression-profile.json"), + )?; + *profile = Some(Arc::new(store)); + Ok(()) + } + /// Queue one observed manual correction. Duplicate pairs are ignored and /// the oldest item is dropped when the bounded card capacity is reached. pub fn queue_pending_correction( @@ -7334,24 +7435,48 @@ impl OpenLessBackend { } } self.disarm_edit_observation(); + let preferences = self.get_preferences(); if !enabled - || !self.get_preferences().vocabulary_learning_enabled + || (!preferences.vocabulary_learning_enabled && !preferences.expression_learning_enabled) || !matches!(insert_outcome, Some(InsertOutcome::Inserted)) || typed_text.trim().is_empty() { return; } 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), + let settings = preferences.vocabulary_learning_settings.normalized(); + let vocabulary = preferences + .vocabulary_learning_enabled + .then(|| CoreEditObservationSink { + settings, + expected_generation, + generation: Arc::clone(&self.edit_observation_generation), + typed_text: typed_text.to_string(), + pending: Arc::clone(&self.pending_corrections), + events: Arc::clone(&self.events), + }); + let expression_profile = if preferences.expression_learning_enabled { + self.expression_profile + .lock() + .ok() + .and_then(|profile| profile.clone()) + } else { + None + }; + if vocabulary.is_none() && expression_profile.is_none() { + return; + } + let sink = Arc::new(CombinedEditObservationSink { + vocabulary, + expression_generation: expression_profile.as_ref().map(|profile| profile.generation()), + expression_profile, typed_text: typed_text.to_string(), - pending: Arc::clone(&self.pending_corrections), - events: Arc::clone(&self.events), + session_id, + clock: Arc::clone(&self.clock), + generation: Arc::clone(&self.edit_observation_generation), + expected_generation, + settings_gate: Arc::clone(&self.settings_write_gate), + duration: std::time::Duration::from_secs(settings.observation_seconds as u64), }); if let Err(error) = self .deps @@ -9320,6 +9445,32 @@ impl OpenLessBackend { options, ); context.pipeline_mode = pipeline_mode; + if purpose == DictationContextPurpose::Dictation + && !context.polish.translation_active + && preferences.expression_learning_enabled + { + if let Ok(_guard) = self.settings_write_gate.try_lock() { + if self.get_preferences().expression_learning_enabled { + if let Some(profile) = self + .expression_profile + .lock() + .ok() + .and_then(|profile| profile.clone()) + { + match profile.stable_directions() { + Ok(directions) => { + if let Some(instructions) = + crate::expression_observation::prompt_instructions(&directions) + { + context.polish.style_system_prompt.push_str(&instructions); + } + } + Err(error) => log::warn!("expression profile unavailable: {error}"), + } + } + } + } + } // Issue #1032: when the LLM channel is not configured, mark the // session as eligible for the Raw fallback instead of dispatching a // polish request that cannot succeed. The context keeps its original @@ -9508,6 +9659,7 @@ mod tests { fn vocabulary_learning_settings_migrate_normalize_and_roundtrip() { let old: UserPreferences = serde_json::from_str("{}").unwrap(); assert!(!old.vocabulary_learning_enabled); + assert!(!old.expression_learning_enabled); assert_eq!(old.vocabulary_learning_settings, Default::default()); let prefs: UserPreferences = serde_json::from_value(serde_json::json!({ "vocabularyLearningSettings": { @@ -9526,6 +9678,130 @@ mod tests { ); } + #[test] + fn expression_observation_obeys_generation_and_never_persists_text() { + let directory = TestDataDir::new("expression-observation"); + let path = directory.path().join("profile.json"); + let profile = Arc::new(crate::expression_observation::ExpressionProfileStore::open(path.clone()).unwrap()); + let generation = Arc::new(AtomicU64::new(4)); + let gate = Arc::new(Mutex::new(())); + let sink = CombinedEditObservationSink { + vocabulary: None, + expression_generation: Some(profile.generation()), + expression_profile: Some(profile), + typed_text: "今天我们先完成这个工作然后再讨论".into(), + session_id: SessionId::new(), + clock: Arc::new(SystemClock), + generation: Arc::clone(&generation), + expected_generation: 4, + settings_gate: Arc::clone(&gate), + duration: std::time::Duration::from_secs(60), + }; + let edit = crate::host_document::EditPair { + source: String::new(), + target: "\n".into(), + before: "今天我们先完成这个工作".into(), + after: "然后再讨论".into(), + }; + { + let _guard = gate.lock().unwrap(); + assert!(!sink.publish(edit.clone())); + assert!(!path.exists()); + } + assert!(sink.publish(edit.clone())); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(!saved.contains("今天")); + generation.fetch_add(1, Ordering::AcqRel); + assert!(!sink.publish(edit)); + assert_eq!(std::fs::read_to_string(&path).unwrap(), saved); + } + + #[tokio::test] + async fn expression_prompt_requires_consent_and_clear_removes_stable_directions() { + let (backend, _) = backend(); + assert!(Arc::ptr_eq(&backend.expression_profile, &backend.repositories().expression_profile)); + backend.set_credential( + crate::CredentialKey::new(crate::CredentialNamespace::Llm, Some("ark".into()), crate::credentials::LLM_API_KEY_ACCOUNT).unwrap(), + crate::SecretValue::new("fixture-key"), + ).await.unwrap(); + let profile = backend.expression_profile.lock().unwrap().as_ref().unwrap().clone(); + let signal = crate::expression_observation::ExpressionSignal { + line_breaks: 1, + exclamations: 0, + terminal_punctuation: 0, + }; + for index in 0..10 { + profile.record(uuid::Uuid::new_v4(), 100 + index % 4, signal).unwrap(); + } + assert_eq!(profile.stable_directions().unwrap().len(), 1); + let options = DictationStartOptions::default(); + let capture = || backend.capture_dictation_context(&options, DictationContextPurpose::Dictation); + assert!(!capture().await.unwrap().polish.style_system_prompt.contains("用户经多次编辑")); + let mut prefs = backend.get_preferences(); + prefs.expression_learning_enabled = true; + backend.set_preferences(prefs).unwrap(); + assert!(backend.get_preferences().expression_learning_enabled); + let context = capture().await.unwrap(); + assert!(context.polish.style_system_prompt.contains("用户经多次编辑"), "translation={} prompt={:?}", context.polish.translation_active, context.polish.style_system_prompt); + backend.clear_expression_profile().unwrap(); + assert!(backend.expression_profile_directions().unwrap().is_empty()); + assert!(!capture().await.unwrap().polish.style_system_prompt.contains("用户经多次编辑")); + } + + #[tokio::test] + async fn expression_capture_does_not_wait_for_busy_settings_gate() { + let (backend, _) = backend(); + let mut prefs = backend.get_preferences(); + prefs.expression_learning_enabled = true; + backend.set_preferences(prefs).unwrap(); + let gate = Arc::clone(&backend.settings_write_gate); + let (locked_tx, locked_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _guard = gate.lock().unwrap(); + locked_tx.send(()).unwrap(); + let _ = release_rx.recv(); + }); + locked_rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap(); + let options = DictationStartOptions::default(); + let context = tokio::time::timeout( + std::time::Duration::from_secs(2), + backend.capture_dictation_context(&options, DictationContextPurpose::Dictation), + ).await.expect("busy settings must not block capture").unwrap(); + release_tx.send(()).unwrap(); + holder.join().unwrap(); + assert!(!context.polish.style_system_prompt.contains("用户经多次编辑")); + } + + #[test] + fn expression_profile_corruption_requires_explicit_clear() { + let directory = TestDataDir::new("expression-corruption"); + let path = directory.path().join("expression-profile.json"); + std::fs::create_dir_all(directory.path()).unwrap(); + std::fs::write(&path, b"unknown profile").unwrap(); + let backend = OpenLessBackend::new( + BackendConfig { data_dir: directory.path().to_path_buf(), ..BackendConfig::default() }, + BackendDependencies::unsupported(), + ).unwrap(); + assert!(backend.expression_profile_directions().is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"unknown profile"); + backend.clear_expression_profile().unwrap(); + assert!(backend.expression_profile_directions().unwrap().is_empty()); + } + + #[test] + fn expression_opt_out_preserves_pending_vocabulary_suggestions() { + let (backend, _) = backend(); + let mut prefs = backend.get_preferences(); + prefs.expression_learning_enabled = true; + prefs.vocabulary_learning_enabled = true; + backend.set_preferences(prefs.clone()).unwrap(); + backend.queue_pending_correction("原词".into(), "新词".into()).unwrap(); + prefs.expression_learning_enabled = false; + backend.update_settings(prefs, crate::SettingsUpdateOptions::STRICT, &crate::NoopSettingsRuntime).unwrap(); + assert_eq!(backend.pending_corrections().len(), 1); + } + #[test] fn vocabulary_learning_config_change_invalidates_pending_and_stale_sink() { let (backend, _) = backend(); @@ -13615,6 +13891,81 @@ mod tests { assert!(backend.pending_corrections().is_empty()); } + #[tokio::test] + async fn expression_learning_can_observe_without_vocabulary_and_stops_on_opt_out() { + let data_dir = TestDataDir::new("expression-consent"); + let observation = Arc::new(FakeEditObservation::default()); + let mut services = crate::domains::BackendServices::unsupported(); + services.edit_observation = observation.clone(); + let text = "今天我们先完成这个工作然后再讨论"; + let backend = OpenLessBackend::new( + BackendConfig { data_dir: data_dir.path().to_path_buf(), ..BackendConfig::default() }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(crate::testing::FixtureDictationEngine::successful(text, text)), + credential_store: Arc::new(crate::credentials::InMemoryCredentialStore::default()), + services, + ..BackendDependencies::unsupported() + }, + ).unwrap(); + let mut prefs = backend.get_preferences(); + assert!(!prefs.expression_learning_enabled); + prefs.expression_learning_enabled = true; + prefs.vocabulary_learning_enabled = false; + backend.set_preferences(prefs).unwrap(); + backend.start().await.unwrap(); + backend.start_dictation().await.unwrap(); + backend.stop_dictation().await.unwrap(); + assert_eq!(observation.typed_texts.lock().unwrap().as_slice(), [text]); + let edit = crate::host_document::EditPair { + source: String::new(), target: "\n".into(), + before: "今天我们先完成这个工作".into(), after: "然后再讨论".into(), + }; + observation.publish(0, edit.clone()); + let path = data_dir.path().join("expression-profile.json"); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(!saved.contains(text)); + assert!(backend.pending_corrections().is_empty()); + let mut prefs = backend.get_preferences(); + prefs.expression_learning_enabled = false; + backend.update_settings(prefs, crate::SettingsUpdateOptions::STRICT, &crate::NoopSettingsRuntime).unwrap(); + observation.publish(0, edit); + assert_eq!(std::fs::read_to_string(&path).unwrap(), saved); + backend.shutdown().await.unwrap(); + } + + #[tokio::test] + async fn expression_opt_out_during_dictation_does_not_arm_an_observer() { + let data_dir = TestDataDir::new("expression-opt-out-during-dictation"); + let observation = Arc::new(FakeEditObservation::default()); + let mut services = crate::domains::BackendServices::unsupported(); + services.edit_observation = observation.clone(); + let backend = OpenLessBackend::new( + BackendConfig { data_dir: data_dir.path().to_path_buf(), ..BackendConfig::default() }, + BackendDependencies { + host_actions: Arc::new(FakeHost::default()), + text_inserter: Arc::new(FakeInserter), + dictation_engine: Arc::new(FakeEngine), + credential_store: Arc::new(crate::credentials::InMemoryCredentialStore::default()), + services, + ..BackendDependencies::unsupported() + }, + ).unwrap(); + let mut prefs = backend.get_preferences(); + prefs.expression_learning_enabled = true; + backend.set_preferences(prefs).unwrap(); + backend.start().await.unwrap(); + backend.start_dictation().await.unwrap(); + let mut prefs = backend.get_preferences(); + prefs.expression_learning_enabled = false; + backend.update_settings(prefs, crate::SettingsUpdateOptions::STRICT, &crate::NoopSettingsRuntime).unwrap(); + backend.stop_dictation().await.unwrap(); + assert!(observation.typed_texts.lock().unwrap().is_empty()); + assert!(!data_dir.path().join("expression-profile.json").exists()); + backend.shutdown().await.unwrap(); + } + #[tokio::test] async fn disabled_vocabulary_learning_is_not_rearmed_by_an_older_dictation() { for streaming in [false, true] { 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 fef46cc5f..580a97c8b 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 @@ -802,6 +802,13 @@ pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ shape: PreferenceShape::Boolean, reason: "local_consent_or_secret", }, + PreferenceField { + rust_name: "expression_learning_enabled", + key: "expressionLearningEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, PreferenceField { rust_name: "vocabulary_learning_settings", key: "vocabularyLearningSettings", 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 369ccf8ea..6916ca4f1 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 @@ -156,11 +156,26 @@ fn testing_features_opt_in_is_never_exported_or_accepted_from_another_device() { ); } +#[test] +fn expression_learning_consent_is_never_accepted_from_another_device() { + let mut invalid = sample().documents().clone(); + invalid.documents.push(LogicalDocument { + id: "expressionLearningEnabled".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!(true), + }); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); +} + #[test] 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,"vocabularyLearningEnabled":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,"expressionLearningEnabled":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(); @@ -172,6 +187,7 @@ fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_gran assert!(!raw.contains("codingAgentEnabled")); assert!(raw.contains("cursorContextEnabled")); assert!(!raw.contains("vocabularyLearningEnabled")); + assert!(!raw.contains("expressionLearningEnabled")); 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 e997f5534..365e0ec00 100644 --- a/openless-all/app/crates/openless-core/src/dictation_context.rs +++ b/openless-all/app/crates/openless-core/src/dictation_context.rs @@ -402,7 +402,8 @@ impl DictationContext { | DictationOutputTarget::Qa | DictationOutputTarget::VoiceEdit ), - observe_edits: preferences.vocabulary_learning_enabled, + observe_edits: preferences.vocabulary_learning_enabled + || preferences.expression_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/expression_observation.rs b/openless-all/app/crates/openless-core/src/expression_observation.rs new file mode 100644 index 000000000..5f4cee41a --- /dev/null +++ b/openless-all/app/crates/openless-core/src/expression_observation.rs @@ -0,0 +1,656 @@ +//! Local, text-free signals for conservative expression learning. +//! +//! The feature and safety model is informed by Type4Me's MIT-licensed +//! ExpressionProfileStore and UserEditClassifier; see type4me-LICENSE.txt. + +use regex::{Regex, RegexSet}; +use serde::{Deserialize, Serialize}; +use std::io::Read; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use unicode_normalization::UnicodeNormalization; + +const MIN_CHARS: usize = 12; +const MAX_CHARS: usize = 16_384; + +/// Only counts and directions leave this module; neither input text is retained. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExpressionSignal { + pub line_breaks: i8, + pub exclamations: i8, + pub terminal_punctuation: i8, +} + +const MAX_EVIDENCE: usize = 1024; +const MIN_DIRECTIONAL_SAMPLES: usize = 10; +const MIN_DAY_SPAN: i32 = 3; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ExpressionFeature { + LineBreaks, + Exclamations, + TerminalPunctuation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StableExpressionDirection { + pub feature: ExpressionFeature, + pub direction: i8, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ExpressionEvidence { + session_id: uuid::Uuid, + day: i32, + signal: ExpressionSignal, +} + +/// Bounded aggregate inputs contain no recognized or edited text. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExpressionProfile { + evidence: Vec, +} + +const MAX_PROFILE_BYTES: u64 = 512 * 1024; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ProfileDocument { + version: u32, + evidence: Vec, +} + +/// The store never retains recognized or edited text and rejects unknown files. +pub struct ExpressionProfileStore { + path: PathBuf, + profile: Mutex>, + generation: AtomicU64, +} + +pub type SharedExpressionProfile = Arc>>>; + +pub fn open_shared_expression_profile(path: PathBuf) -> SharedExpressionProfile { + let profile = match ExpressionProfileStore::open(path) { + Ok(store) => Some(Arc::new(store)), + Err(error) => { + log::warn!("expression profile unavailable: {error}"); + None + } + }; + Arc::new(Mutex::new(profile)) +} + +impl ExpressionProfileStore { + /// Explicit user reset can recover an unreadable local profile. + pub fn reset(path: PathBuf) -> Result { + crate::persistence::atomic_write(&path, br#"{"version":1,"evidence":[]}"#)?; + Self::open(path) + } + + pub fn open(path: PathBuf) -> Result { + let mut profile = ExpressionProfile::default(); + match std::fs::File::open(&path) { + Ok(file) => { + let mut data = Vec::new(); + file.take(MAX_PROFILE_BYTES + 1) + .read_to_end(&mut data) + .map_err(|_| { + crate::persistence::persistence_error("read expression profile") + })?; + if data.len() as u64 > MAX_PROFILE_BYTES { + return Err(crate::persistence::persistence_error( + "expression profile too large", + )); + } + let document: ProfileDocument = serde_json::from_slice(&data).map_err(|_| { + crate::persistence::persistence_error("decode expression profile") + })?; + if document.version != 1 || document.evidence.len() > MAX_EVIDENCE { + return Err(crate::persistence::persistence_error( + "invalid expression profile", + )); + } + for evidence in document.evidence { + if !profile.observe(evidence.session_id, evidence.day, evidence.signal) { + return Err(crate::persistence::persistence_error( + "invalid expression evidence", + )); + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => { + return Err(crate::persistence::persistence_error( + "read expression profile", + )) + } + } + Ok(Self { + path, + profile: Mutex::new(Some(profile)), + generation: AtomicU64::new(0), + }) + } + + pub fn generation(&self) -> u64 { + self.generation.load(Ordering::Acquire) + } + + pub fn invalidate_observers(&self) -> Result<(), crate::errors::BackendError> { + let _profile = self + .profile + .lock() + .map_err(|_| crate::persistence::persistence_error("lock expression profile"))?; + self.generation.fetch_add(1, Ordering::AcqRel); + Ok(()) + } + + pub fn record( + &self, + session_id: uuid::Uuid, + day: i32, + signal: ExpressionSignal, + ) -> Result { + self.record_if_generation(session_id, day, signal, self.generation()) + } + + pub fn record_if_generation( + &self, + session_id: uuid::Uuid, + day: i32, + signal: ExpressionSignal, + expected_generation: u64, + ) -> Result { + let mut profile = self + .profile + .lock() + .map_err(|_| crate::persistence::persistence_error("lock expression profile"))?; + if self.generation.load(Ordering::Acquire) != expected_generation { + return Ok(false); + } + let mut next = profile + .as_ref() + .ok_or_else(|| { + crate::persistence::persistence_error("expression profile needs reload") + })? + .clone(); + if !next.observe(session_id, day, signal) { + return Ok(false); + } + if let Err(error) = self.persist(&next) { + *profile = None; + return Err(error); + } + *profile = Some(next); + Ok(true) + } + + pub fn stable_directions( + &self, + ) -> Result, crate::errors::BackendError> { + let profile = self + .profile + .lock() + .map_err(|_| crate::persistence::persistence_error("lock expression profile"))?; + Ok(profile + .as_ref() + .ok_or_else(|| { + crate::persistence::persistence_error("expression profile needs reload") + })? + .stable_directions()) + } + + pub fn clear(&self) -> Result<(), crate::errors::BackendError> { + let mut profile = self + .profile + .lock() + .map_err(|_| crate::persistence::persistence_error("lock expression profile"))?; + self.generation.fetch_add(1, Ordering::AcqRel); + if profile.is_none() { + return Err(crate::persistence::persistence_error( + "expression profile needs reload", + )); + } + let next = ExpressionProfile::default(); + if let Err(error) = self.persist(&next) { + *profile = None; + return Err(error); + } + *profile = Some(next); + Ok(()) + } + + fn persist(&self, profile: &ExpressionProfile) -> Result<(), crate::errors::BackendError> { + let data = serde_json::to_vec(&ProfileDocument { + version: 1, + evidence: profile.evidence.clone(), + }) + .map_err(|_| crate::persistence::persistence_error("encode expression profile"))?; + crate::persistence::atomic_write(&self.path, &data) + } +} + +impl ExpressionProfile { + pub fn observe(&mut self, session_id: uuid::Uuid, day: i32, signal: ExpressionSignal) -> bool { + if self + .evidence + .iter() + .any(|item| item.session_id == session_id) + || [ + signal.line_breaks, + signal.exclamations, + signal.terminal_punctuation, + ] + .iter() + .any(|direction| !(-1..=1).contains(direction)) + || signal + == (ExpressionSignal { + line_breaks: 0, + exclamations: 0, + terminal_punctuation: 0, + }) + { + return false; + } + self.evidence.push(ExpressionEvidence { + session_id, + day, + signal, + }); + self.evidence.sort_unstable_by_key(|item| item.day); + if self.evidence.len() > MAX_EVIDENCE { + self.evidence.drain(..self.evidence.len() - MAX_EVIDENCE); + } + true + } + + pub fn stable_directions(&self) -> Vec { + type FeatureSelector = (ExpressionFeature, fn(ExpressionSignal) -> i8); + let selectors: [FeatureSelector; 3] = [ + (ExpressionFeature::LineBreaks, |signal| signal.line_breaks), + (ExpressionFeature::Exclamations, |signal| { + signal.exclamations + }), + (ExpressionFeature::TerminalPunctuation, |signal| { + signal.terminal_punctuation + }), + ]; + selectors + .into_iter() + .filter_map(|(feature, select)| { + let directional: Vec<_> = self + .evidence + .iter() + .filter_map(|item| { + let value = select(item.signal); + (value != 0).then_some((item.day, value)) + }) + .collect(); + let first = directional.first()?.0; + let last = directional.last()?.0; + if directional.len() < MIN_DIRECTIONAL_SAMPLES + || last.saturating_sub(first) < MIN_DAY_SPAN + { + return None; + } + let positive = directional + .iter() + .filter(|(_, direction)| *direction > 0) + .count(); + let negative = directional.len() - positive; + let dominant = positive.max(negative); + (dominant * 10 >= directional.len() * 7).then_some(StableExpressionDirection { + feature, + direction: if positive > negative { 1 } else { -1 }, + }) + }) + .collect() + } +} + +pub fn prompt_instructions(directions: &[StableExpressionDirection]) -> Option { + let statements: Vec<&str> = directions + .iter() + .filter_map(|item| match (item.feature, item.direction) { + (ExpressionFeature::LineBreaks, 1) => Some("用户通常偏好更清晰的分行。"), + (ExpressionFeature::LineBreaks, -1) => Some("用户通常偏好较少的分行。"), + (ExpressionFeature::Exclamations, 1) => Some("用户通常偏好使用感叹号。"), + (ExpressionFeature::Exclamations, -1) => Some("用户通常偏好少用感叹号。"), + (ExpressionFeature::TerminalPunctuation, 1) => Some("用户通常偏好句末标点。"), + (ExpressionFeature::TerminalPunctuation, -1) => { + Some("在不影响语义时,用户通常偏好省略陈述句末标点;保留问号和必要标点。") + } + _ => None, + }) + .collect(); + if statements.is_empty() { + return None; + } + Some(format!("\n\n用户经多次编辑形成的形式偏好(仅供参考;不得改变事实、否定、原意、语气、明确指令或必要结构):\n{}", statements.join("\n"))) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservationRejection { + Unchanged, + InsufficientOrOversized, + Sensitive, + ContentOrAmbiguous, +} + +fn sensitive_patterns() -> &'static RegexSet { + static PATTERNS: OnceLock = OnceLock::new(); + PATTERNS.get_or_init(|| { + RegexSet::new([ + r"(?i)\b[\w.%+-]+@[\w.-]+\.[a-z]{2,}\b", + r"(?i)(?:https?://|www\.)\S+", + r"(?:\d[ -]?){7,}", + r"(?:^|\s)(?:/[^\s]+|[a-zA-Z]:\\[^\s]+)", + r"\b[A-Za-z0-9_-]{20,}\b", + ]) + .expect("fixed sensitivity expressions are valid") + }) +} + +fn ascii_words() -> &'static Regex { + static WORDS: OnceLock = OnceLock::new(); + WORDS.get_or_init(|| Regex::new(r"[a-z0-9]+").expect("fixed word expression is valid")) +} + +fn normalized(text: &str) -> String { + text.nfkc().flat_map(char::to_lowercase).collect() +} + +fn skeleton(text: &str) -> String { + text.chars() + .filter(|character| !character.is_whitespace() && !",.;:!,。;:!、".contains(*character)) + .collect() +} + +fn direction(before: usize, after: usize) -> i8 { + match after.cmp(&before) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + } +} + +fn count(text: &str, chars: &[char]) -> usize { + text.chars() + .filter(|character| chars.contains(character)) + .count() +} + +fn ends_in_terminal_punctuation(text: &str) -> usize { + usize::from( + text.trim_end() + .chars() + .last() + .is_some_and(|c| ".!?。!?".contains(c)), + ) +} + +/// Accept only a narrow set of formatting edits while preserving symbols, +/// question marks, content and Latin word boundaries. +pub fn expression_signal( + original: &str, + edited: &str, +) -> Result { + if original == edited { + return Err(ObservationRejection::Unchanged); + } + let original_length = original.chars().count(); + let edited_length = edited.chars().count(); + if !(MIN_CHARS..=MAX_CHARS).contains(&original_length) + || !(MIN_CHARS..=MAX_CHARS).contains(&edited_length) + { + return Err(ObservationRejection::InsufficientOrOversized); + } + let original_normalized = normalized(original); + let edited_normalized = normalized(edited); + if sensitive_patterns().is_match(&original_normalized) + || sensitive_patterns().is_match(&edited_normalized) + { + return Err(ObservationRejection::Sensitive); + } + if skeleton(&original_normalized) != skeleton(&edited_normalized) + || !ascii_words() + .find_iter(&original_normalized) + .map(|word| word.as_str()) + .eq(ascii_words() + .find_iter(&edited_normalized) + .map(|word| word.as_str())) + { + return Err(ObservationRejection::ContentOrAmbiguous); + } + Ok(ExpressionSignal { + line_breaks: direction(count(original, &['\n']), count(edited, &['\n'])), + exclamations: direction(count(original, &['!', '!']), count(edited, &['!', '!'])), + terminal_punctuation: direction( + ends_in_terminal_punctuation(original), + ends_in_terminal_punctuation(edited), + ), + }) +} + +/// Rebuild one edited insertion only when the source and surrounding context +/// identify a unique location within the text that OpenLess actually inserted. +pub fn signal_from_observed_edit( + inserted: &str, + edit: &crate::host_document::EditPair, +) -> Result { + if inserted.chars().count() > MAX_CHARS { + return Err(ObservationRejection::InsufficientOrOversized); + } + let before_anchor: String = edit + .before + .chars() + .rev() + .take(16) + .collect::() + .chars() + .rev() + .collect(); + let after_anchor: String = edit.after.chars().take(16).collect(); + let mut candidate = None; + for (offset, _) in inserted + .char_indices() + .chain(std::iter::once((inserted.len(), '\0'))) + { + let Some(remaining) = inserted[offset..].strip_prefix(&edit.source) else { + continue; + }; + if !inserted[..offset].ends_with(&before_anchor) || !remaining.starts_with(&after_anchor) { + continue; + } + if candidate.is_some() { + return Err(ObservationRejection::ContentOrAmbiguous); + } + candidate = Some((offset, offset + edit.source.len())); + } + let (start, end) = candidate.ok_or(ObservationRejection::ContentOrAmbiguous)?; + let edited = format!("{}{}{}", &inserted[..start], edit.target, &inserted[end..]); + expression_signal(inserted, &edited) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_formatting_only_and_emits_no_text() { + let signal = expression_signal( + "今天我们先完成这个工作然后再讨论", + "今天我们先完成这个工作,\n然后再讨论!", + ) + .unwrap(); + assert_eq!(signal.line_breaks, 1); + assert_eq!(signal.exclamations, 1); + assert_eq!(signal.terminal_punctuation, 1); + let serialized = serde_json::to_string(&signal).unwrap(); + assert!(!serialized.contains("今天")); + } + + #[test] + fn rejects_changed_facts_negation_and_word_boundaries() { + for (before, after) in [ + ("今天我们需要购买十二个苹果", "今天我们需要购买十三个苹果"), + ("这件事情现在我们不能这样做", "这件事情现在我们能这样做"), + ( + "This is a notable result today", + "This is a not able result today", + ), + ("请把这个结果标记为通过 ✅", "请把这个结果标记为通过 ❌"), + ("这段程序应该使用 C++ 版本", "这段程序应该使用 C# 版本"), + ] { + assert_eq!( + expression_signal(before, after), + Err(ObservationRejection::ContentOrAmbiguous) + ); + } + } + + #[test] + fn only_unambiguous_edits_inside_the_inserted_text_are_observed() { + let inserted = "今天我们先完成这个工作然后再讨论"; + let edit = crate::host_document::EditPair { + source: String::new(), + target: "\n".into(), + before: "今天我们先完成这个工作".into(), + after: "然后再讨论".into(), + }; + assert_eq!( + signal_from_observed_edit(inserted, &edit) + .unwrap() + .line_breaks, + 1 + ); + let ambiguous = crate::host_document::EditPair { + source: ",".into(), + target: "。".into(), + before: String::new(), + after: String::new(), + }; + assert_eq!( + signal_from_observed_edit("这是一个足够长的句子,里面还有另一个,分隔符", &ambiguous), + Err(ObservationRejection::ContentOrAmbiguous) + ); + assert_eq!( + signal_from_observed_edit(inserted, &ambiguous), + Err(ObservationRejection::ContentOrAmbiguous) + ); + } + + #[test] + fn rejects_sensitive_and_unbounded_observations() { + assert_eq!( + expression_signal( + "Email me at user@example.com please", + "Email me at user@example.com, please!" + ), + Err(ObservationRejection::Sensitive) + ); + assert_eq!( + expression_signal("短句", "短句!"), + Err(ObservationRejection::InsufficientOrOversized) + ); + let long = "测".repeat(MAX_CHARS + 1); + assert_eq!( + expression_signal(&long, &(long.clone() + "!")), + Err(ObservationRejection::InsufficientOrOversized) + ); + } + + #[test] + fn stable_directions_require_independent_consistent_samples_across_days() { + let mut profile = ExpressionProfile::default(); + let id = uuid::Uuid::new_v4(); + let positive = ExpressionSignal { + line_breaks: 1, + exclamations: 0, + terminal_punctuation: 0, + }; + assert!(profile.observe(id, 100, positive)); + assert!(!profile.observe(id, 103, positive)); + for index in 0..9 { + assert!(profile.observe(uuid::Uuid::new_v4(), 100 + (index % 3), positive)); + } + assert!(profile.stable_directions().is_empty()); + assert!(profile.observe(uuid::Uuid::new_v4(), 103, positive)); + assert_eq!( + profile.stable_directions(), + vec![StableExpressionDirection { + feature: ExpressionFeature::LineBreaks, + direction: 1, + }] + ); + let negative = ExpressionSignal { + line_breaks: -1, + ..positive + }; + for _ in 0..8 { + assert!(profile.observe(uuid::Uuid::new_v4(), 104, negative)); + } + assert!(profile.stable_directions().is_empty()); + } + + #[test] + fn profile_store_roundtrips_abstract_evidence_and_clears() { + let directory = + std::env::temp_dir().join(format!("openless-expression-{}", uuid::Uuid::new_v4())); + let path = directory.join("profile.json"); + let store = ExpressionProfileStore::open(path.clone()).unwrap(); + let signal = ExpressionSignal { + line_breaks: 1, + exclamations: 0, + terminal_punctuation: 0, + }; + for index in 0..10 { + assert!(store + .record(uuid::Uuid::new_v4(), 100 + (index % 4), signal) + .unwrap()); + } + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(!saved.contains("今天")); + drop(store); + let reloaded = ExpressionProfileStore::open(path.clone()).unwrap(); + assert_eq!(reloaded.stable_directions().unwrap().len(), 1); + let previous_generation = reloaded.generation(); + reloaded.clear().unwrap(); + assert!(!reloaded + .record_if_generation(uuid::Uuid::new_v4(), 104, signal, previous_generation) + .unwrap()); + let opted_in_generation = reloaded.generation(); + reloaded.invalidate_observers().unwrap(); + assert!(!reloaded + .record_if_generation(uuid::Uuid::new_v4(), 105, signal, opted_in_generation) + .unwrap()); + drop(reloaded); + assert!(ExpressionProfileStore::open(path) + .unwrap() + .stable_directions() + .unwrap() + .is_empty()); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn corrupt_and_unknown_profile_is_not_overwritten() { + let directory = + std::env::temp_dir().join(format!("openless-expression-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("profile.json"); + let content = br#"{"version":1,"evidence":[],"newField":"keep"}"#; + std::fs::write(&path, content).unwrap(); + assert!(ExpressionProfileStore::open(path.clone()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), content); + let reset = ExpressionProfileStore::reset(path).unwrap(); + assert!(reset.stable_directions().unwrap().is_empty()); + std::fs::remove_dir_all(directory).unwrap(); + } +} diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 1ee73c76a..2f4892f00 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -36,6 +36,7 @@ pub mod edit_plan; pub mod endpoint_security; pub mod errors; pub mod events; +pub mod expression_observation; pub mod experimental_asr; pub mod external_audio; pub mod history; 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 e441d4505..fd30899f6 100644 --- a/openless-all/app/crates/openless-core/src/shared_types.rs +++ b/openless-all/app/crates/openless-core/src/shared_types.rs @@ -840,6 +840,9 @@ pub struct UserPreferences { /// Observe corrections locally after insertion; independent of LLM context. #[serde(default)] pub vocabulary_learning_enabled: bool, + /// Local consent for observing formatting edits as abstract expression evidence. + #[serde(default)] + pub expression_learning_enabled: bool, #[serde(default)] pub vocabulary_learning_settings: VocabularyLearningSettings, /// Whether the Overview shows the "yearly activity" heatmap card. @@ -1226,6 +1229,8 @@ struct UserPreferencesWire { cursor_context_enabled: bool, #[serde(default)] vocabulary_learning_enabled: bool, + #[serde(default)] + expression_learning_enabled: bool, vocabulary_learning_settings: VocabularyLearningSettings, #[serde(default = "default_true")] show_overview_activity_heatmap: bool, @@ -1431,6 +1436,7 @@ impl Default for UserPreferencesWire { windows_composition_input: prefs.windows_composition_input, cursor_context_enabled: prefs.cursor_context_enabled, vocabulary_learning_enabled: prefs.vocabulary_learning_enabled, + expression_learning_enabled: prefs.expression_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, @@ -1671,6 +1677,7 @@ impl<'de> Deserialize<'de> for UserPreferences { windows_composition_input: wire.windows_composition_input, cursor_context_enabled: wire.cursor_context_enabled, vocabulary_learning_enabled: wire.vocabulary_learning_enabled, + expression_learning_enabled: wire.expression_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, @@ -2054,6 +2061,7 @@ impl Default for UserPreferences { windows_composition_input: false, cursor_context_enabled: true, vocabulary_learning_enabled: false, + expression_learning_enabled: false, vocabulary_learning_settings: VocabularyLearningSettings::default(), show_overview_activity_heatmap: true, stacked_row_layout: false, diff --git a/openless-all/app/e2e/refinements.spec.ts b/openless-all/app/e2e/refinements.spec.ts index 4b559b38e..6851a3dc5 100644 --- a/openless-all/app/e2e/refinements.spec.ts +++ b/openless-all/app/e2e/refinements.spec.ts @@ -30,6 +30,13 @@ test('awareness settings have a dedicated advanced page', async ({ page }) => { await dialog.locator('[data-ol-advanced-entry="cursorContext"]').click(); await expect(dialog.locator('[data-ol-advanced-page="cursorContext"]')).toBeVisible(); await expect(dialog.getByText('手改学词', { exact: true })).toBeVisible(); + const expression = dialog.getByRole('switch', { name: '学习表达习惯', exact: true }); + await expect(expression).toBeVisible(); + await expect(expression).toHaveAttribute('aria-checked', 'false'); + await expression.click(); + await expect(expression).toHaveAttribute('aria-checked', 'true'); + await dialog.getByRole('button', { name: '清除表达习惯', exact: true }).click(); + await expect(dialog.getByText('已形成 0 项稳定偏好')).toBeVisible(); }); test('shortcut search and categories live in the flat settings pane; keyboard close preserves exit', async ({ diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index 51bfc707f..e16a3c9a6 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 199, + "total": 201, "wired": 103, - "missing": 96, + "missing": 98, "commands": [ { "name": "accept_pending_correction", @@ -109,6 +109,13 @@ ], "implementation": "arkts" }, + { + "name": "clear_expression_profile", + "calledFrom": [ + "src/lib/ipc/expression.ts" + ], + "implementation": "unimplemented" + }, { "name": "clear_history", "calledFrom": [ @@ -487,6 +494,13 @@ ], "implementation": "unimplemented" }, + { + "name": "get_expression_profile", + "calledFrom": [ + "src/lib/ipc/expression.ts" + ], + "implementation": "unimplemented" + }, { "name": "get_hotkey_capability", "calledFrom": [ diff --git a/openless-all/app/public/licenses/type4me-LICENSE.txt b/openless-all/app/public/licenses/type4me-LICENSE.txt new file mode 100644 index 000000000..dfc042e14 --- /dev/null +++ b/openless-all/app/public/licenses/type4me-LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Type4Me Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/openless-all/app/src-tauri/src/commands/dictionary.rs b/openless-all/app/src-tauri/src/commands/dictionary.rs index 52bf7ec08..e96ebb074 100644 --- a/openless-all/app/src-tauri/src/commands/dictionary.rs +++ b/openless-all/app/src-tauri/src/commands/dictionary.rs @@ -43,6 +43,18 @@ pub fn list_correction_rules(core: CoreState<'_>) -> Result, core.list_correction_rules().map_err(|e| e.to_string()) } +#[tauri::command] +pub fn get_expression_profile(window: Window, core: CoreState<'_>) -> Result, String> { + if window.label() != "main" { return Err("expression profile is only available from the main window".into()); } + core.expression_profile_directions().map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn clear_expression_profile(window: Window, core: CoreState<'_>) -> Result<(), String> { + if window.label() != "main" { return Err("expression profile is only available from the main window".into()); } + core.clear_expression_profile().map_err(|error| error.to_string()) +} + #[tauri::command] pub fn add_correction_rule( core: CoreState<'_>, diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c180668fd..21c78fc39 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -443,6 +443,9 @@ fn shared_backend_from_stores( preferences: prefs.core(), history: history.core(), activity: activity.core(), + expression_profile: openless_core::expression_observation::open_shared_expression_profile( + config.data_dir.join("expression-profile.json"), + ), vocabulary: vocab.core(), correction_rules: correction_rules.core(), style_packs: style_packs.core(), diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index b7d269dae..8d1806608 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -4802,7 +4802,8 @@ impl TauriTextInsertionSession { .as_ref() .and_then(|b| b.upgrade()) .is_some_and(|b| { - b.get_preferences().vocabulary_learning_enabled + let prefs = b.get_preferences(); + prefs.vocabulary_learning_enabled || prefs.expression_learning_enabled }) }, || { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1116e7eed..3ae3cd7bc 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -364,6 +364,8 @@ macro_rules! app_invoke_handler_desktop { commands::set_vocab_enabled, commands::update_vocab, commands::list_correction_rules, + commands::get_expression_profile, + commands::clear_expression_profile, commands::add_correction_rule, commands::remove_correction_rule, commands::set_correction_rule_enabled, @@ -677,6 +679,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::set_vocab_enabled, $crate::commands::update_vocab, $crate::commands::list_correction_rules, + $crate::commands::get_expression_profile, + $crate::commands::clear_expression_profile, $crate::commands::add_correction_rule, $crate::commands::remove_correction_rule, $crate::commands::set_correction_rule_enabled, diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 8ec7fb5b6..aad2801d6 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1387,6 +1387,24 @@ 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.', }, + expressionLearning: { + clearHint: + 'Nach dem Löschen kann ein laufendes Diktat noch die beim Start erfassten Vorlieben verwenden.', + lineBreaksMore: 'Mehr Zeilenumbrüche', + lineBreaksLess: 'Weniger Zeilenumbrüche', + exclamationsMore: 'Mehr Ausrufezeichen', + exclamationsLess: 'Weniger Ausrufezeichen', + terminalPunctuationMore: 'Satzzeichen am Ende verwenden', + terminalPunctuationLess: 'Weniger Satzzeichen am Ende', + + title: 'Ausdrucksgewohnheiten', + enabled: 'Ausdrucksgewohnheiten lernen', + description: + 'Beobachtet Formatänderungen nach dem Diktat lokal; Ausdrucksdaten speichern nur abstrakte Statistiken. Der Verlauf wird separat verwaltet. Wiederholte, konsistente Zeilenumbruch- und Satzzeichenpräferenzen werden als kurze Anweisungen an das gewählte Textmodell gesendet. Ausschalten beendet das Lernen sofort und die Anwendung ab dem nächsten Diktat; ein laufendes Diktat kann noch erfasste Vorlieben verwenden.', + status: '{{count}} stabile Vorlieben', + clear: 'Ausdrucksgewohnheiten löschen', + error: 'Ausdrucksgewohnheiten konnten nicht gelesen oder gespeichert werden.', + }, vocabularyLearning: { title: 'Aus Korrekturen lernen', enabled: 'Lernen aus Korrekturen aktivieren', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index bed02b217..b80dfe03c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1360,6 +1360,24 @@ export const en: typeof zhCN = { text: 'Text and context', diagnostics: 'Diagnostics', }, + expressionLearning: { + clearHint: + 'After clearing, an in-progress dictation may still use the preferences captured when it started.', + lineBreaksMore: 'More line breaks', + lineBreaksLess: 'Fewer line breaks', + exclamationsMore: 'More exclamation marks', + exclamationsLess: 'Fewer exclamation marks', + terminalPunctuationMore: 'Use terminal punctuation', + terminalPunctuationLess: 'Use less terminal punctuation', + + title: 'Expression habits', + enabled: 'Learn expression habits', + description: + 'Observes formatting edits locally after dictation; expression data stores abstract statistics, not observed text. History is managed separately. Repeated, consistent line-break and punctuation preferences become short instructions sent to the selected text model. Turning this off stops learning immediately and applying preferences to new dictations; an in-progress dictation may still use its captured preferences.', + status: '{{count}} stable preferences', + clear: 'Clear expression habits', + error: 'Could not read or save expression habits. Try again.', + }, vocabularyLearning: { title: 'Learn from corrections', enabled: 'Enable learning from corrections', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 5bad7cca5..1c289166d 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1380,6 +1380,24 @@ 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.', }, + expressionLearning: { + clearHint: + 'Tras borrar, un dictado en curso puede seguir usando las preferencias capturadas al comenzar.', + lineBreaksMore: 'Más saltos de línea', + lineBreaksLess: 'Menos saltos de línea', + exclamationsMore: 'Más signos de exclamación', + exclamationsLess: 'Menos signos de exclamación', + terminalPunctuationMore: 'Usar puntuación final', + terminalPunctuationLess: 'Reducir la puntuación final', + + title: 'Hábitos de expresión', + enabled: 'Aprender hábitos de expresión', + description: + 'Observa localmente los cambios de formato tras el dictado; los datos de expresión solo guardan estadísticas abstractas. El historial se gestiona por separado. Las preferencias repetidas y coherentes se envían como instrucciones breves al modelo de texto elegido. Al desactivarlo, el aprendizaje se detiene de inmediato y deja de aplicarse a los siguientes dictados; uno en curso puede seguir usando las preferencias ya capturadas.', + status: '{{count}} preferencias estables', + clear: 'Borrar hábitos', + error: 'No se pudieron leer o guardar los hábitos.', + }, vocabularyLearning: { title: 'Aprender de las correcciones', enabled: 'Activar aprendizaje de correcciones', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 9242a129d..c6bf8471b 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1400,6 +1400,24 @@ 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.', }, + expressionLearning: { + clearHint: + 'Après effacement, une dictée en cours peut encore utiliser les préférences capturées à son démarrage.', + lineBreaksMore: 'Plus de sauts de ligne', + lineBreaksLess: 'Moins de sauts de ligne', + exclamationsMore: 'Plus de points d’exclamation', + exclamationsLess: 'Moins de points d’exclamation', + terminalPunctuationMore: 'Utiliser la ponctuation finale', + terminalPunctuationLess: 'Réduire la ponctuation finale', + + title: 'Habitudes d’expression', + enabled: 'Apprendre les habitudes', + description: + 'Observe localement les modifications après dictée ; les données d’expression ne stockent que des statistiques abstraites. L’historique est géré séparément. Les préférences répétées et cohérentes deviennent de courtes instructions envoyées au modèle de texte choisi. La désactivation arrête immédiatement l’apprentissage et leur application aux prochaines dictées ; une dictée en cours peut encore utiliser les préférences déjà capturées.', + status: '{{count}} préférences stables', + clear: 'Effacer les habitudes', + error: 'Impossible de lire ou enregistrer les habitudes.', + }, vocabularyLearning: { title: 'Apprendre des corrections', enabled: 'Activer l’apprentissage des corrections', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index ae26f709d..63ce28a9f 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1349,6 +1349,23 @@ export const ja: typeof zhCN = { useSystemProxyDesc: 'オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。', }, + expressionLearning: { + clearHint: '消去後も、進行中の音声入力には開始時の好みが使われる場合があります。', + lineBreaksMore: '改行を増やす', + lineBreaksLess: '改行を減らす', + exclamationsMore: '感嘆符を増やす', + exclamationsLess: '感嘆符を減らす', + terminalPunctuationMore: '文末の句読点を使う', + terminalPunctuationLess: '文末の句読点を減らす', + + title: '表現の習慣', + enabled: '表現の習慣を学習', + description: + '音声入力後の書式変更を端末内で観察し、表現の習慣には抽象的な統計のみを保存します。履歴は別に管理されます。繰り返し確認された改行や句読点の傾向を、選択した文字モデルへの短い指示として利用します。オフにすると学習は直ちに停止し、次の音声入力から適用されません。進行中の入力には取得済みの好みが使われる場合があります。', + status: '安定した好み {{count}} 件', + clear: '表現の習慣を消去', + error: '表現の習慣を読み書きできませんでした。', + }, vocabularyLearning: { title: '修正から単語を学習', enabled: '修正からの学習を有効にする', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 742be1d7d..5e7983337 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1341,6 +1341,23 @@ export const ko: typeof zhCN = { useSystemProxyDesc: '켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.', }, + expressionLearning: { + clearHint: '삭제 후에도 진행 중인 받아쓰기에는 시작할 때 캡처한 선호가 사용될 수 있습니다.', + lineBreaksMore: '줄바꿈 늘리기', + lineBreaksLess: '줄바꿈 줄이기', + exclamationsMore: '느낌표 늘리기', + exclamationsLess: '느낌표 줄이기', + terminalPunctuationMore: '문장 끝 구두점 사용', + terminalPunctuationLess: '문장 끝 구두점 줄이기', + + title: '표현 습관', + enabled: '표현 습관 학습', + description: + '받아쓰기 후 서식 수정을 기기에서만 관찰하며 표현 습관에는 추상적인 통계만 저장합니다. 기록은 별도로 관리됩니다. 반복되고 일관된 줄바꿈과 문장 부호 선호를 선택한 텍스트 모델에 짧은 지시로 전달합니다. 끄면 학습은 즉시 중단되고 다음 받아쓰기부터 적용되지 않습니다. 진행 중인 받아쓰기에는 이미 캡처한 선호가 사용될 수 있습니다.', + status: '안정된 선호 {{count}}개', + clear: '표현 습관 지우기', + error: '표현 습관을 읽거나 저장하지 못했습니다.', + }, vocabularyLearning: { title: '수정으로 단어 학습', enabled: '수정 학습 사용', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e1031ae86..614894979 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1302,6 +1302,23 @@ export const zhCN = { startupDiagnosticsTitle: '启动诊断', cursorContextHint: '光标上下文默认开启。如果不希望润色请求携带附近文字,可以在这里关闭。', }, + expressionLearning: { + clearHint: '清除后,正在进行的听写可能仍使用开始时已捕获的偏好。', + lineBreaksMore: '更常分行', + lineBreaksLess: '减少分行', + exclamationsMore: '更常使用感叹号', + exclamationsLess: '减少感叹号', + terminalPunctuationMore: '保留句末标点', + terminalPunctuationLess: '减少句末标点', + + title: '表达习惯', + enabled: '学习表达习惯', + description: + '仅在本机观察听写后的排版修改;表达习惯数据只保存抽象统计,不保存观察到的文本。历史记录另行管理。重复且一致的分行、感叹号和句末标点偏好达到稳定阈值后,会作为简短形式指令发送给所选文字模型;关闭后立即停止学习,并从下一次听写起停止应用;正在进行的听写可能仍使用已捕获的偏好。', + status: '已形成 {{count}} 项稳定偏好', + clear: '清除表达习惯', + error: '无法读取或保存表达习惯,请重试。', + }, vocabularyLearning: { title: '手改学词', enabled: '启用手改学词', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index f1b066571..12015d81b 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1303,6 +1303,23 @@ export const zhTW: typeof zhCN = { useSystemProxyDesc: '開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。', }, + expressionLearning: { + clearHint: '清除後,進行中的聽寫可能仍使用開始時已擷取的偏好。', + lineBreaksMore: '更常分行', + lineBreaksLess: '減少分行', + exclamationsMore: '更常使用驚嘆號', + exclamationsLess: '減少驚嘆號', + terminalPunctuationMore: '保留句末標點', + terminalPunctuationLess: '減少句末標點', + + title: '表達習慣', + enabled: '學習表達習慣', + description: + '僅在本機觀察聽寫後的排版修改;表達習慣資料只儲存抽象統計,不儲存觀察到的文字。歷史記錄另行管理。反覆且一致的分行、驚嘆號及句末標點偏好穩定後,會以簡短形式指令傳給所選文字模型;關閉後立即停止學習,並從下一次聽寫起停止套用;進行中的聽寫可能仍使用已擷取的偏好。', + status: '已形成 {{count}} 項穩定偏好', + clear: '清除表達習慣', + error: '無法讀取或儲存表達習慣,請重試。', + }, vocabularyLearning: { title: '手改學詞', enabled: '啟用手改學詞', diff --git a/openless-all/app/src/lib/ipc/expression.ts b/openless-all/app/src/lib/ipc/expression.ts new file mode 100644 index 000000000..eb62ade98 --- /dev/null +++ b/openless-all/app/src/lib/ipc/expression.ts @@ -0,0 +1,14 @@ +import { invokeOrMock } from './shared'; + +export interface StableExpressionDirection { + feature: 'lineBreaks' | 'exclamations' | 'terminalPunctuation'; + direction: -1 | 1; +} + +export function getExpressionProfile(): Promise { + return invokeOrMock('get_expression_profile', undefined, () => []); +} + +export function clearExpressionProfile(): Promise { + return invokeOrMock('clear_expression_profile', undefined, () => undefined); +} diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index b3566fb4d..87a801d2b 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -138,6 +138,7 @@ export let mockSettings: UserPreferences = { windowsCompositionInput: false, cursorContextEnabled: true, vocabularyLearningEnabled: false, + expressionLearningEnabled: false, vocabularyLearningSettings: { observationSeconds: 60, suggestionSeconds: 10, maxPhraseChars: 12 }, showOverviewActivityHeatmap: true, stackedRowLayout: false, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 7c5393edf..5796d1cb5 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -574,6 +574,7 @@ export interface UserPreferences { * managers / terminals are always hard-blocked. */ cursorContextEnabled: boolean; vocabularyLearningEnabled: boolean; + expressionLearningEnabled: boolean; vocabularyLearningSettings: { observationSeconds: number; suggestionSeconds: number; diff --git a/openless-all/app/src/pages/settings/ExpressionLearningSection.tsx b/openless-all/app/src/pages/settings/ExpressionLearningSection.tsx new file mode 100644 index 000000000..ea78d1ba6 --- /dev/null +++ b/openless-all/app/src/pages/settings/ExpressionLearningSection.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + clearExpressionProfile, + getExpressionProfile, + type StableExpressionDirection, +} from '../../lib/ipc/expression'; +import { useHotkeySettings } from '../../state/HotkeySettingsContext'; +import { Card } from '../_atoms'; +import { btnGhostStyle, ExperimentalSectionTitle, SettingRow, Toggle } from './shared'; + +export function ExpressionLearningSection() { + const { t } = useTranslation(); + const { prefs, updatePrefs } = useHotkeySettings(); + const [directions, setDirections] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(false); + useEffect(() => { + let alive = true; + void getExpressionProfile().then( + (directions) => { + if (alive) setDirections(directions); + }, + () => { + if (alive) setError(true); + }, + ); + return () => { + alive = false; + }; + }, []); + if (!prefs) return null; + return ( + + + {t('settings.expressionLearning.title')} + + + { + setBusy(true); + setError(false); + try { + await updatePrefs((current) => ({ ...current, expressionLearningEnabled: next })); + if (directions === null) setError(true); + } catch { + setError(true); + } finally { + setBusy(false); + } + }} + /> + +

+ {directions === null + ? error + ? '' + : t('common.loading') + : t('settings.expressionLearning.status', { count: directions.length })} +

+ {directions && directions.length > 0 && ( +
    + {directions.map((item) => ( +
  • + {t( + `settings.expressionLearning.${item.feature}${item.direction > 0 ? 'More' : 'Less'}`, + )} +
  • + ))} +
+ )} + +

+ {t('settings.expressionLearning.clearHint')} +

+ {error &&

{t('settings.expressionLearning.error')}

} +
+ ); +} diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index ed6a8bcfd..c2a835e93 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -27,6 +27,7 @@ import { LocalModelsSection } from './models/LocalModelsSection'; import { LocalModelsNavContext } from './models/modelsNav'; import { DebugToolsSection } from './DebugToolsSection'; import { VocabularyLearningSection } from './VocabularyLearningSection'; +import { ExpressionLearningSection } from './ExpressionLearningSection'; import { AudioDspSettings } from './AudioDspSettings'; import { CodingAgentSection } from './CodingAgentSection'; import { ClaudeConsoleSection } from './ClaudeConsoleSection'; @@ -789,6 +790,8 @@ export function AdvancedTab({ {showsCursorContextControl(platformCaps?.platform, detectOS()) && ( )} + {platformCaps?.platform === 'desktop' && + (detectOS() === 'mac' || detectOS() === 'win') && } )} {item.id === 'startupDiagnostics' && } diff --git a/openless-all/app/src/styles/refinements.css b/openless-all/app/src/styles/refinements.css index aba90c9a1..125d10c65 100644 --- a/openless-all/app/src/styles/refinements.css +++ b/openless-all/app/src/styles/refinements.css @@ -164,6 +164,9 @@ button:disabled { outline-offset: 1px; box-shadow: inset 0 0 0 1px var(--ol-blue); } +.ol-expression-clear { + --ol-corner-radius: 8px; +} .ol-shortcuts-toolbar { display: flex; gap: 12px;