diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ce71a71..974066a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -96,6 +96,126 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -232,6 +352,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -551,6 +684,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -1177,6 +1319,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1189,6 +1337,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1231,6 +1400,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1444,6 +1634,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2668,6 +2871,7 @@ version = "0.7.5" dependencies = [ "aho-corasick", "async-trait", + "base64 0.22.1", "bytes", "candle-core", "candle-nn", @@ -2693,6 +2897,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tempfile", "tokenizers", "tokio", @@ -2916,6 +3121,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -3226,6 +3445,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "notify-types" version = "1.0.1" @@ -3562,6 +3795,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "outref" version = "0.5.2" @@ -3593,6 +3836,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3849,6 +4098,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "piston-float" version = "1.0.1" @@ -3906,6 +4166,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "pom" version = "1.1.0" @@ -4984,6 +5258,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5491,6 +5775,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.1" @@ -5593,6 +5896,17 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6057,6 +6371,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ug" version = "0.1.0" @@ -7283,6 +7608,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.1", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.1", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -7383,3 +7769,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.1", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.1", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2906d32..3d3f34f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2.6", features = [] } # 与前端 @tauri-apps/api 保持同一 minor(Tauri CLI 会校验),勿只写 major=2 导致与 npm 漂移 tauri = { version = "2.11", features = ["macos-private-api"] } tauri-plugin-dialog = "2.7" +tauri-plugin-notification = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" url = "2.5" @@ -49,6 +50,7 @@ async-trait = "0.1" semver = { version = "1", features = ["serde"] } jsonschema = "0.28" dashmap = "6" +base64 = "0.22" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a8c80c0..5f0ed98 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-minimize", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", - "dialog:default" + "dialog:default", + "notification:default" ] } diff --git a/src-tauri/src/challenge_feedback.rs b/src-tauri/src/challenge_feedback.rs new file mode 100644 index 0000000..7734e16 --- /dev/null +++ b/src-tauri/src/challenge_feedback.rs @@ -0,0 +1,325 @@ +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde::Serialize; + +pub fn init_feedback_table(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS challenge_feedback ( + id TEXT PRIMARY KEY, + thought_id TEXT, + question_text TEXT NOT NULL, + question_template TEXT, + rating TEXT NOT NULL, + rating_reason TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cf_rating ON challenge_feedback(rating); + CREATE INDEX IF NOT EXISTS idx_cf_template ON challenge_feedback(question_template); + CREATE INDEX IF NOT EXISTS idx_cf_thought_id ON challenge_feedback(thought_id); + "#, + ) + .map_err(|e| format!("init challenge_feedback table: {e}"))?; + Ok(()) +} + +pub fn insert_feedback( + conn: &Connection, + thought_id: Option<&str>, + question_text: &str, + question_template: Option<&str>, + rating: &str, + rating_reason: Option<&str>, +) -> Result<(), String> { + let id = format!("cf-{}", uuid::Uuid::new_v4()); + let now = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO challenge_feedback (id, thought_id, question_text, question_template, rating, rating_reason, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![id, thought_id, question_text, question_template, rating, rating_reason, now], + ) + .map_err(|e| format!("insert challenge feedback: {e}"))?; + Ok(()) +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct TemplateStats { + pub template: String, + pub total: usize, + pub helpful: usize, + pub not_helpful: usize, + pub helpful_rate: f64, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct IssueCount { + pub reason: String, + pub count: usize, +} + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackStats { + pub total_ratings: usize, + pub helpful_count: usize, + pub not_helpful_count: usize, + pub helpful_rate: f64, + pub by_template: Vec, + pub common_issues: Vec, +} + +pub fn query_feedback_stats(conn: &Connection) -> Result { + let helpful_count: usize = conn + .query_row( + "SELECT COUNT(*) FROM challenge_feedback WHERE rating = 'helpful'", + [], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + let not_helpful_count: usize = conn + .query_row( + "SELECT COUNT(*) FROM challenge_feedback WHERE rating = 'not_helpful'", + [], + |r| r.get(0), + ) + .map_err(|e| e.to_string())?; + let total_ratings = helpful_count + not_helpful_count; + let helpful_rate = if total_ratings > 0 { + helpful_count as f64 / total_ratings as f64 + } else { + 0.0 + }; + + let mut stmt = conn + .prepare( + "SELECT question_template, rating, COUNT(*) as cnt + FROM challenge_feedback + WHERE question_template IS NOT NULL + GROUP BY question_template, rating + ORDER BY question_template", + ) + .map_err(|e| e.to_string())?; + let rows: Vec<(String, String, usize)> = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, usize>(2)?, + )) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + let mut template_map: std::collections::HashMap = + std::collections::HashMap::new(); + for (tmpl, rating, cnt) in &rows { + let entry = template_map.entry(tmpl.clone()).or_insert((0, 0)); + match rating.as_str() { + "helpful" => entry.0 += cnt, + "not_helpful" => entry.1 += cnt, + _ => {} + } + } + let mut by_template: Vec = template_map + .into_iter() + .map(|(template, (h, nh))| { + let total = h + nh; + TemplateStats { + template, + total, + helpful: h, + not_helpful: nh, + helpful_rate: if total > 0 { h as f64 / total as f64 } else { 0.0 }, + } + }) + .collect(); + by_template.sort_by(|a, b| a.template.cmp(&b.template)); + + let mut issue_stmt = conn + .prepare( + "SELECT rating_reason, COUNT(*) as cnt + FROM challenge_feedback + WHERE rating_reason IS NOT NULL AND rating_reason != '' + GROUP BY rating_reason + ORDER BY cnt DESC", + ) + .map_err(|e| e.to_string())?; + let common_issues: Vec = issue_stmt + .query_map([], |row| { + Ok(IssueCount { + reason: row.get(0)?, + count: row.get(1)?, + }) + }) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + Ok(FeedbackStats { + total_ratings, + helpful_count, + not_helpful_count, + helpful_rate, + by_template, + common_issues, + }) +} + +pub fn query_recent_questions( + conn: &Connection, + thought_id: &str, + limit: usize, +) -> Result, String> { + let cap = limit.min(20); + let mut stmt = conn + .prepare( + "SELECT question_text FROM challenge_feedback + WHERE thought_id = ?1 + ORDER BY created_at DESC + LIMIT ?2", + ) + .map_err(|e| format!("prepare recent questions: {e}"))?; + let rows = stmt + .query_map(params![thought_id, cap as i64], |row| row.get::<_, String>(0)) + .map_err(|e| format!("query recent questions: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read recent questions: {e}")) +} + +#[tauri::command] +pub async fn submit_challenge_feedback( + state: tauri::State<'_, crate::WorkspaceState>, + thought_id: Option, + question_text: String, + question_template: Option, + rating: String, + rating_reason: Option, +) -> Result<(), String> { + let root = crate::lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = crate::vault_thoughts_db::open_thoughts_db(&root)?; + insert_feedback( + &conn, + thought_id.as_deref(), + &question_text, + question_template.as_deref(), + &rating, + rating_reason.as_deref(), + ) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn get_feedback_stats( + state: tauri::State<'_, crate::WorkspaceState>, +) -> Result { + let root = crate::lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = crate::vault_thoughts_db::open_thoughts_db(&root)?; + query_feedback_stats(&conn) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn setup_db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + init_feedback_table(&conn).unwrap(); + conn + } + + #[test] + fn insert_and_query_stats() { + let conn = setup_db(); + insert_feedback(&conn, Some("t1"), "What is X?", Some("compare"), "helpful", None).unwrap(); + insert_feedback(&conn, Some("t2"), "Explain Y", Some("apply"), "not_helpful", Some("too_easy")).unwrap(); + insert_feedback(&conn, Some("t3"), "Compare A", Some("compare"), "helpful", None).unwrap(); + + let stats = query_feedback_stats(&conn).unwrap(); + assert_eq!(stats.total_ratings, 3); + assert_eq!(stats.helpful_count, 2); + assert_eq!(stats.not_helpful_count, 1); + assert!((stats.helpful_rate - 2.0 / 3.0).abs() < 0.01); + + let compare = stats.by_template.iter().find(|t| t.template == "compare").unwrap(); + assert_eq!(compare.helpful, 2); + assert_eq!(compare.not_helpful, 0); + + let apply = stats.by_template.iter().find(|t| t.template == "apply").unwrap(); + assert_eq!(apply.helpful, 0); + assert_eq!(apply.not_helpful, 1); + + assert_eq!(stats.common_issues.len(), 1); + assert_eq!(stats.common_issues[0].reason, "too_easy"); + assert_eq!(stats.common_issues[0].count, 1); + } + + #[test] + fn empty_stats() { + let conn = setup_db(); + let stats = query_feedback_stats(&conn).unwrap(); + assert_eq!(stats.total_ratings, 0); + assert_eq!(stats.helpful_rate, 0.0); + assert!(stats.by_template.is_empty()); + assert!(stats.common_issues.is_empty()); + } + + #[test] + fn query_recent_questions_returns_latest_n() { + let conn = setup_db(); + for i in 0..7 { + insert_feedback( + &conn, + Some("t1"), + &format!("Question {i}"), + Some("apply"), + "helpful", + None, + ) + .unwrap(); + } + let qs = query_recent_questions(&conn, "t1", 5).unwrap(); + assert_eq!(qs.len(), 5); + assert_eq!(qs[0], "Question 6"); + assert_eq!(qs[4], "Question 2"); + } + + #[test] + fn query_recent_questions_filters_by_thought_id() { + let conn = setup_db(); + insert_feedback(&conn, Some("t1"), "Q for t1", Some("apply"), "helpful", None).unwrap(); + insert_feedback(&conn, Some("t2"), "Q for t2", Some("apply"), "helpful", None).unwrap(); + insert_feedback(&conn, Some("t1"), "Q2 for t1", Some("compare"), "helpful", None).unwrap(); + + let qs = query_recent_questions(&conn, "t1", 10).unwrap(); + assert_eq!(qs.len(), 2); + assert!(qs.iter().all(|q| q.contains("t1"))); + + let empty = query_recent_questions(&conn, "unknown", 10).unwrap(); + assert!(empty.is_empty()); + } + + #[test] + fn multiple_reasons() { + let conn = setup_db(); + insert_feedback(&conn, None, "Q1", None, "not_helpful", Some("too_vague")).unwrap(); + insert_feedback(&conn, None, "Q2", None, "not_helpful", Some("too_vague")).unwrap(); + insert_feedback(&conn, None, "Q3", None, "not_helpful", Some("irrelevant")).unwrap(); + + let stats = query_feedback_stats(&conn).unwrap(); + assert_eq!(stats.common_issues[0].reason, "too_vague"); + assert_eq!(stats.common_issues[0].count, 2); + assert_eq!(stats.common_issues[1].reason, "irrelevant"); + assert_eq!(stats.common_issues[1].count, 1); + } +} diff --git a/src-tauri/src/challenge_prompts.rs b/src-tauri/src/challenge_prompts.rs new file mode 100644 index 0000000..14642de --- /dev/null +++ b/src-tauri/src/challenge_prompts.rs @@ -0,0 +1,351 @@ +use crate::challenge_feedback::FeedbackStats; +use crate::vault_config::DepthMode; + +pub const BASE_SYSTEM_PROMPT: &str = r#"You design ONE short challenge question to help the user revisit a saved thought from their notes. + +Pick the best template kind: +- "compare": contrast two ideas or test whether a distinction still holds in a scenario. +- "apply": ask them to apply the thought to a new concrete situation. +- "critique": challenge an implicit assumption politely. +- "transfer": ask whether an idea from domain A could inform domain B. + +Rules: +- The question must be answerable in a few sentences; no multi-part essays. +- If the user message includes a "UI locale" line, write the `question` in that language (English vs Chinese) regardless of excerpt language. +- Otherwise match the thought excerpt language (Chinese excerpt → Chinese question; English → English). +- Respond with ONE JSON object only (no markdown fences, no prose). Keys (camelCase): + - "question": string (non-empty unless skipped) + - "templateKind": one of compare | apply | critique | transfer + - "skipped": boolean — true if the excerpt is too thin or unsafe to challenge; then set question to "". + +Example: {"question":"...","templateKind":"apply","skipped":false}"#; + +pub const FALLBACK_CHALLENGE_QUESTION_ZH: &str = + "你之前写过这个想法,现在还同意这个观点吗?"; + +pub const FALLBACK_CHALLENGE_QUESTION_EN: &str = + "You wrote this idea before — do you still agree with it?"; + +pub(crate) fn ui_locale_is_zh(ui_locale: Option<&str>) -> bool { + matches!( + ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("zh" | "zh-cn" | "zh-hans" | "zh-hant" | "zh-tw") + ) +} + +pub(crate) fn ui_locale_is_en(ui_locale: Option<&str>) -> bool { + matches!( + ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("en") | Some("en-us") | Some("en-gb") + ) +} + +pub(crate) fn generate_ui_locale_paragraph(ui_locale: Option<&str>) -> &'static str { + if ui_locale_is_zh(ui_locale) { + "UI locale: Chinese (Simplified). Write the JSON `question` field in natural Chinese (简体中文), even if the excerpt is in another language." + } else if ui_locale_is_en(ui_locale) { + "UI locale: English. Write the JSON `question` field in English, even if the excerpt is in another language." + } else { + "Language: If no UI locale was specified, match the thought excerpt language for the question." + } +} + +pub(crate) fn depth_tone_line(d: DepthMode) -> &'static str { + match d { + DepthMode::Shallow => "Keep the challenge question very short (one sentence).", + DepthMode::Medium => "Keep the challenge question concise (1-2 sentences).", + DepthMode::Deep => { + "You may use a slightly richer challenge question (still under 3 sentences)." + } + DepthMode::Auto => "Keep the challenge question concise (1-2 sentences).", + } +} + +pub(crate) fn candidate_degraded_question( + reason: &str, + _excerpt: &str, + paired: Option<&str>, + locale: Option<&str>, +) -> String { + let is_en = ui_locale_is_en(locale); + match reason { + "high_similarity" => { + if let Some(p) = paired { + if is_en { + format!("Your notes contain similar content in another file ({p}). What's the unique perspective in this paragraph?") + } else { + format!( + "你的笔记在另一个文件({p})中有类似内容。这段话的独特之处是什么?" + ) + } + } else if is_en { + "Your notes contain similar paragraphs. What's the key difference between them?" + .to_string() + } else { + "你的笔记中有多段相似内容,它们的核心区别是什么?".to_string() + } + } + "semantic_isolated" => { + if is_en { + "This idea seems isolated from your other notes. What connections can you draw to other topics you've written about?".to_string() + } else { + "这个想法和你的其他笔记似乎没有关联。你能找到它与其他主题之间的联系吗?" + .to_string() + } + } + "cross_doc_recurrence" => { + if is_en { + "A similar concept appears across several of your notes. Has your understanding of it evolved over time?".to_string() + } else { + "你在多篇笔记中提到了类似的概念,你对它的理解有变化吗?".to_string() + } + } + _ => { + if is_en { + "What's the core insight in this paragraph, and do you still agree with it?" + .to_string() + } else { + "这段话的核心观点是什么?你现在还同意吗?".to_string() + } + } + } +} + +pub(crate) fn normalize_template_kind(raw: Option<&str>) -> String { + let s = raw.unwrap_or("apply").trim().to_ascii_lowercase(); + match s.as_str() { + "compare" | "comparison" => "compare".to_string(), + "critique" | "critical" => "critique".to_string(), + "transfer" | "migration" => "transfer".to_string(), + "apply" | "application" | _ => "apply".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Dynamic system prompt construction +// --------------------------------------------------------------------------- + +const WEIGHT_MIN_SAMPLES: usize = 10; +const WEIGHT_HIGH_RATE: f64 = 0.7; +const WEIGHT_LOW_RATE: f64 = 0.4; + +const ISSUE_THRESHOLD: usize = 5; +const ISSUE_DUPLICATE_THRESHOLD: usize = 3; + +fn build_template_weight_hint(stats: &FeedbackStats) -> Option { + let mut lines = Vec::new(); + for ts in &stats.by_template { + if ts.total < WEIGHT_MIN_SAMPLES { + continue; + } + if ts.helpful_rate < WEIGHT_LOW_RATE { + lines.push(format!( + "- Avoid the \"{}\" template — users find it unhelpful.", + ts.template + )); + } else if ts.helpful_rate > WEIGHT_HIGH_RATE { + lines.push(format!( + "- The \"{}\" template works well — consider using it.", + ts.template + )); + } + } + if lines.is_empty() { + None + } else { + Some(format!( + "Template preferences based on user feedback:\n{}", + lines.join("\n") + )) + } +} + +fn build_issue_hint(stats: &FeedbackStats) -> Option { + let mut lines = Vec::new(); + for issue in &stats.common_issues { + match issue.reason.as_str() { + "too_easy" if issue.count >= ISSUE_THRESHOLD => { + lines.push( + "- Ask at a deeper level; avoid surface-level recall questions.".to_string(), + ); + } + "irrelevant" if issue.count >= ISSUE_THRESHOLD => { + lines.push( + "- The question MUST directly reference specific content from the excerpt." + .to_string(), + ); + } + "too_vague" if issue.count >= ISSUE_THRESHOLD => { + lines.push( + "- Be specific; reference exact concepts, terms, or claims from the text." + .to_string(), + ); + } + "duplicate" if issue.count >= ISSUE_DUPLICATE_THRESHOLD => { + lines.push( + "- Vary your question style across template kinds.".to_string(), + ); + } + _ => {} + } + } + if lines.is_empty() { + None + } else { + Some(format!( + "Additional rules based on past feedback:\n{}", + lines.join("\n") + )) + } +} + +pub fn build_system_prompt(stats: Option<&FeedbackStats>) -> String { + let mut prompt = BASE_SYSTEM_PROMPT.to_string(); + if let Some(s) = stats { + if let Some(hint) = build_template_weight_hint(s) { + prompt.push_str("\n\n"); + prompt.push_str(&hint); + } + if let Some(hint) = build_issue_hint(s) { + prompt.push_str("\n\n"); + prompt.push_str(&hint); + } + } + prompt +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::challenge_feedback::{IssueCount, TemplateStats}; + + #[test] + fn normalize_template_kind_aliases() { + assert_eq!(normalize_template_kind(Some("compare")), "compare"); + assert_eq!(normalize_template_kind(Some("comparison")), "compare"); + assert_eq!(normalize_template_kind(Some("critique")), "critique"); + assert_eq!(normalize_template_kind(Some("critical")), "critique"); + assert_eq!(normalize_template_kind(Some("transfer")), "transfer"); + assert_eq!(normalize_template_kind(Some("migration")), "transfer"); + assert_eq!(normalize_template_kind(Some("apply")), "apply"); + assert_eq!(normalize_template_kind(Some("application")), "apply"); + assert_eq!(normalize_template_kind(Some("unknown")), "apply"); + assert_eq!(normalize_template_kind(None), "apply"); + } + + fn make_stats( + templates: Vec<(&str, usize, usize)>, + issues: Vec<(&str, usize)>, + ) -> FeedbackStats { + let by_template: Vec = templates + .into_iter() + .map(|(name, helpful, not_helpful)| { + let total = helpful + not_helpful; + TemplateStats { + template: name.to_string(), + total, + helpful, + not_helpful, + helpful_rate: if total > 0 { + helpful as f64 / total as f64 + } else { + 0.0 + }, + } + }) + .collect(); + let common_issues = issues + .into_iter() + .map(|(reason, count)| IssueCount { + reason: reason.to_string(), + count, + }) + .collect(); + let helpful_count: usize = by_template.iter().map(|t: &TemplateStats| t.helpful).sum(); + let not_helpful_count: usize = by_template + .iter() + .map(|t: &TemplateStats| t.not_helpful) + .sum(); + let total = helpful_count + not_helpful_count; + FeedbackStats { + total_ratings: total, + helpful_count, + not_helpful_count, + helpful_rate: if total > 0 { + helpful_count as f64 / total as f64 + } else { + 0.0 + }, + by_template, + common_issues, + } + } + + #[test] + fn build_template_weight_hint_none_when_insufficient_data() { + let stats = make_stats(vec![("apply", 3, 2), ("compare", 1, 1)], vec![]); + assert!(build_template_weight_hint(&stats).is_none()); + } + + #[test] + fn build_template_weight_hint_surfaces_extreme_templates() { + let stats = make_stats( + vec![ + ("apply", 9, 1), // 10 samples, 0.9 rate → prefer + ("critique", 2, 10), // 12 samples, 0.17 rate → avoid + ("compare", 3, 2), // 5 samples → skip (below threshold) + ], + vec![], + ); + let hint = build_template_weight_hint(&stats).unwrap(); + assert!(hint.contains("\"apply\" template works well")); + assert!(hint.contains("Avoid the \"critique\"")); + assert!(!hint.contains("compare")); + } + + #[test] + fn build_issue_hint_none_when_no_issues() { + let stats = make_stats(vec![], vec![]); + assert!(build_issue_hint(&stats).is_none()); + } + + #[test] + fn build_issue_hint_triggers_on_threshold() { + let stats = make_stats( + vec![], + vec![ + ("too_easy", 6), + ("too_vague", 5), + ("irrelevant", 4), // below threshold + ("duplicate", 3), + ], + ); + let hint = build_issue_hint(&stats).unwrap(); + assert!(hint.contains("deeper level")); + assert!(hint.contains("Be specific")); + assert!(!hint.contains("MUST directly reference")); // irrelevant below threshold + assert!(hint.contains("Vary your question style")); + } + + #[test] + fn build_system_prompt_base_only_without_stats() { + let prompt = build_system_prompt(None); + assert_eq!(prompt, BASE_SYSTEM_PROMPT); + } + + #[test] + fn build_system_prompt_appends_hints() { + let stats = make_stats( + vec![("apply", 9, 1)], // 10 samples, high rate + vec![("too_easy", 7)], + ); + let prompt = build_system_prompt(Some(&stats)); + assert!(prompt.starts_with(BASE_SYSTEM_PROMPT)); + assert!(prompt.contains("Template preferences")); + assert!(prompt.contains("Additional rules")); + } +} diff --git a/src-tauri/src/challenge_review.rs b/src-tauri/src/challenge_review.rs index ea69839..f264ec7 100644 --- a/src-tauri/src/challenge_review.rs +++ b/src-tauri/src/challenge_review.rs @@ -23,16 +23,29 @@ use crate::{is_markdown_path, join_under_root, sanitize_io_error}; pub struct ApplyChallengePassArgs { pub rel_path: String, pub thought_id: String, - /// 未通过或敷衍时不写回元数据 + /// Challenge passed cleanly #[serde(default = "default_passed_true")] pub passed: bool, + /// Sloppy attempt (tried but halfhearted) + #[serde(default)] + pub sloppy: bool, } fn default_passed_true() -> bool { true } -/// 读改写落盘:将挑战通过状态写入笔记 Markdown。 +fn args_to_quality(args: &ApplyChallengePassArgs) -> thought_parser::ChallengeQuality { + if args.passed && !args.sloppy { + thought_parser::ChallengeQuality::Passed + } else if args.sloppy { + thought_parser::ChallengeQuality::Sloppy + } else { + thought_parser::ChallengeQuality::Failed + } +} + +/// 读改写落盘:将挑战回顾状态(SM-2 调度)写入笔记 Markdown。 /// /// 写入采用同目录临时文件 + `rename`(与 `atomic_write_string_in_parent` / `vault_config::atomic_write_json` 同类), /// 避免并发 `fs::write` 同一路径导致截断或读到半成品;**不**解决两路读改写逻辑冲突(仍依赖调用方串行或业务层协调)。 @@ -54,12 +67,13 @@ pub fn apply_challenge_pass_blocking( } let content = fs::read_to_string(&canonical_file).map_err(|e| sanitize_io_error(e, "reading file"))?; + let quality = args_to_quality(&args); let outcome = thought_parser::apply_challenge_pass_to_markdown_vault( canonical_root, &rel_path, &content, &args.thought_id, - args.passed, + quality, )?; if outcome.markdown == content { return Ok(None); @@ -68,27 +82,13 @@ pub fn apply_challenge_pass_blocking( Ok(outcome.maturity_change) } -// --- LLM:生成挑战问句 --- - -/// 与主流式隔离的 system 提示(英文),输出 JSON。 -const SYSTEM_CHALLENGE_GENERATE: &str = r#"You design ONE short challenge question to help the user revisit a saved thought from their notes. - -Pick the best template kind: -- "compare": contrast two ideas or test whether a distinction still holds in a scenario. -- "apply": ask them to apply the thought to a new concrete situation. -- "critique": challenge an implicit assumption politely. -- "transfer": ask whether an idea from domain A could inform domain B. +use crate::challenge_prompts::{ + self, candidate_degraded_question, depth_tone_line, generate_ui_locale_paragraph, + normalize_template_kind, ui_locale_is_en, ui_locale_is_zh, FALLBACK_CHALLENGE_QUESTION_EN, + FALLBACK_CHALLENGE_QUESTION_ZH, +}; -Rules: -- The question must be answerable in a few sentences; no multi-part essays. -- If the user message includes a "UI locale" line, write the `question` in that language (English vs Chinese) regardless of excerpt language. -- Otherwise match the thought excerpt language (Chinese excerpt → Chinese question; English → English). -- Respond with ONE JSON object only (no markdown fences, no prose). Keys (camelCase): - - "question": string (non-empty unless skipped) - - "templateKind": one of compare | apply | critique | transfer - - "skipped": boolean — true if the excerpt is too thin or unsafe to challenge; then set question to "". - -Example: {"question":"...","templateKind":"apply","skipped":false}"#; +// --- LLM:生成挑战问句 --- #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -113,6 +113,12 @@ pub struct GenerateChallengeQuestionArgs { /// 与前端 Knowforge 语言一致:`en` / `zh`(可选,缺省则按摘录语言推断问句语言) #[serde(default)] pub ui_locale: Option, + #[serde(default)] + pub marking_reason: Option, + #[serde(default)] + pub paired_excerpt: Option, + #[serde(default)] + pub thought_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -178,36 +184,6 @@ pub struct EvaluateChallengeAnswerResponse { pub template_kind: Option, } -/// 通道一/二共用的降级问句(中文,与产品文档一致) -pub const FALLBACK_CHALLENGE_QUESTION_ZH: &str = "你之前写过这个想法,现在还同意这个观点吗?"; - -pub const FALLBACK_CHALLENGE_QUESTION_EN: &str = - "You wrote this idea before — do you still agree with it?"; - -pub(crate) fn ui_locale_is_zh(ui_locale: Option<&str>) -> bool { - matches!( - ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("zh" | "zh-cn" | "zh-hans" | "zh-hant" | "zh-tw") - ) -} - -fn ui_locale_is_en(ui_locale: Option<&str>) -> bool { - matches!( - ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("en") | Some("en-us") | Some("en-gb") - ) -} - -/// 注入用户消息块,约束问句自然语言与 Knowforge 界面一致。 -fn generate_ui_locale_paragraph(ui_locale: Option<&str>) -> &'static str { - if ui_locale_is_zh(ui_locale) { - "UI locale: Chinese (Simplified). Write the JSON `question` field in natural Chinese (简体中文), even if the excerpt is in another language." - } else if ui_locale_is_en(ui_locale) { - "UI locale: English. Write the JSON `question` field in English, even if the excerpt is in another language." - } else { - "Language: If no UI locale was specified, match the thought excerpt language for the question." - } -} fn evaluate_ui_locale_paragraph(ui_locale: Option<&str>) -> &'static str { if ui_locale_is_zh(ui_locale) { @@ -251,24 +227,6 @@ fn resolve_depth_for_challenge(depth: Option, query_opt: Option<&str> } } -fn depth_tone_line(d: DepthMode) -> &'static str { - match d { - DepthMode::Shallow => "Keep the challenge question very short (one sentence).", - DepthMode::Medium => "Keep the challenge question concise (1-2 sentences).", - DepthMode::Deep => "You may use a slightly richer challenge question (still under 3 sentences).", - DepthMode::Auto => "Keep the challenge question concise (1-2 sentences).", - } -} - -fn normalize_template_kind(raw: Option<&str>) -> String { - let s = raw.unwrap_or("apply").trim().to_ascii_lowercase(); - match s.as_str() { - "compare" | "comparison" => "compare".to_string(), - "critique" | "critical" => "critique".to_string(), - "transfer" | "migration" => "transfer".to_string(), - "apply" | "application" | _ => "apply".to_string(), - } -} /// 生成挑战问句(失败时 `should_skip=true` 供通道二静默) #[tauri::command] @@ -278,6 +236,8 @@ pub async fn generate_challenge_question( args: GenerateChallengeQuestionArgs, ) -> Result { let root = crate::lock_workspace_root(&workspace)?; + let root_for_stats = root.clone(); + let thought_id_for_dedup = args.thought_id.clone(); let ai = tauri::async_runtime::spawn_blocking(move || { let ai = vault_config::load_ai_config_internal(&root)?; Ok::<_, String>(ai) @@ -285,9 +245,41 @@ pub async fn generate_challenge_question( .await .map_err(|e| e.to_string())??; + let (recent_qs, feedback_stats) = tauri::async_runtime::spawn_blocking(move || { + let conn = match crate::vault_thoughts_db::open_thoughts_db(&root_for_stats) { + Ok(c) => c, + Err(_) => return (Vec::new(), None), + }; + let qs = match &thought_id_for_dedup { + Some(tid) if !tid.is_empty() => { + crate::challenge_feedback::query_recent_questions(&conn, tid, 5) + .unwrap_or_default() + } + _ => Vec::new(), + }; + let stats = crate::challenge_feedback::query_feedback_stats(&conn).ok(); + (qs, stats) + }) + .await + .unwrap_or((Vec::new(), None)); + let provider = match create_provider(&ai, None, http_client.inner()) { Ok(p) => p, Err(_) => { + if let Some(ref reason) = args.marking_reason { + let q = candidate_degraded_question( + reason, + args.thought_excerpt.trim(), + args.paired_excerpt.as_deref(), + args.ui_locale.as_deref(), + ); + return Ok(GenerateChallengeQuestionResponse { + question: q, + template_kind: "apply".to_string(), + degraded: true, + should_skip: false, + }); + } return Ok(GenerateChallengeQuestionResponse { question: String::new(), template_kind: "apply".to_string(), @@ -307,16 +299,37 @@ pub async fn generate_challenge_question( }); } + let is_candidate = args.marking_reason.is_some(); + let depth = resolve_depth_for_challenge( args.depth_mode, args.conversation_query.as_deref(), ); let tone = depth_tone_line(depth); - let mut user_block = format!( - "Source note path (for context only): `{}`\n\nSaved thought excerpt:\n---\n{}\n---\n", - args.rel_path.trim(), - excerpt - ); + let mut user_block = if is_candidate { + let reason_hint = match args.marking_reason.as_deref() { + Some("high_similarity") => "This paragraph was flagged because it is very similar to content in another note.", + Some("semantic_isolated") => "This paragraph was flagged because it seems disconnected from the user's other notes.", + Some("cross_doc_recurrence") => "This paragraph was flagged because a similar concept appears across multiple notes.", + _ => "", + }; + let mut b = format!( + "Source note path: `{}`\n\nThis is a paragraph from the user's notes (not yet a formal Thought):\n---\n{}\n---\n{}\n", + args.rel_path.trim(), + excerpt, + reason_hint, + ); + if let Some(ref paired) = args.paired_excerpt { + b.push_str(&format!("\nRelated paragraph from another note:\n---\n{paired}\n---\n")); + } + b + } else { + format!( + "Source note path (for context only): `{}`\n\nSaved thought excerpt:\n---\n{}\n---\n", + args.rel_path.trim(), + excerpt, + ) + }; if let Some(ref q) = args.conversation_query { let t = q.trim(); if !t.is_empty() { @@ -328,10 +341,17 @@ pub async fn generate_challenge_question( generate_ui_locale_paragraph(args.ui_locale.as_deref()) )); + if !recent_qs.is_empty() { + user_block.push_str("\n\nPrevious questions asked about this content (DO NOT repeat these):"); + for (i, q) in recent_qs.iter().enumerate() { + user_block.push_str(&format!("\n{}. \"{}\"", i + 1, q)); + } + } + let msgs = vec![ LlmChatMessage { role: "system".into(), - content: SYSTEM_CHALLENGE_GENERATE.to_string(), + content: challenge_prompts::build_system_prompt(feedback_stats.as_ref()), ..Default::default() }, LlmChatMessage { @@ -347,10 +367,17 @@ pub async fn generate_challenge_question( }; let raw = provider.chat_completion(&msgs, Some(&overrides)).await; - let fallback_q = if ui_locale_is_en(args.ui_locale.as_deref()) { - FALLBACK_CHALLENGE_QUESTION_EN + let fallback_q = if is_candidate { + candidate_degraded_question( + args.marking_reason.as_deref().unwrap_or(""), + excerpt, + args.paired_excerpt.as_deref(), + args.ui_locale.as_deref(), + ) + } else if ui_locale_is_en(args.ui_locale.as_deref()) { + FALLBACK_CHALLENGE_QUESTION_EN.to_string() } else { - FALLBACK_CHALLENGE_QUESTION_ZH + FALLBACK_CHALLENGE_QUESTION_ZH.to_string() }; let raw = match raw { @@ -532,8 +559,8 @@ pub async fn evaluate_challenge_answer( // --- 回顾队列:遗忘曲线 MVP + 日 cap 顺延(`.knowforge/challenge-review-cap-state.json`) --- -/// 排期间隔(天):第 n 次成功回顾后的下一次间隔取下标 `min(n,4)`(与迭代 4 文档 §5 对齐)。 -const REVIEW_INTERVALS_DAYS: &[i64] = &[1, 3, 7, 14, 30]; +/// Legacy fixed intervals (kept only for `from_legacy` migration path in SrsState). +const _LEGACY_REVIEW_INTERVALS_DAYS: &[i64] = &[1, 3, 7, 14, 30]; const CAP_STATE_FILE: &str = ".knowforge/challenge-review-cap-state.json"; @@ -616,6 +643,65 @@ fn today_inline_thought_blocklist( .unwrap_or_default() } +fn mix_latent_candidates(canonical_root: &Path, today_key: &str) -> Vec { + let embed_conn = match crate::semantic_index::open_embedding_db(canonical_root) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + let candidates = match crate::latent_paragraphs::list_candidates(&embed_conn, 20) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + candidates + .into_iter() + .map(|c| ReviewQueueItem { + rel_path: c.rel_path, + thought_id: String::new(), + excerpt: c.excerpt, + maturity: thought_parser::ThoughtMaturity::Seedling, + created: String::new(), + last_reviewed_at: None, + challenge_pass_count: 0, + next_due_at: today_key.to_string(), + overdue_days: 0, + private_omitted: false, + source_type: "candidate".to_string(), + candidate_id: Some(c.id), + marking_reason: Some(c.marking_reason), + paired_excerpt: c.paired_rel_path, + start_line: Some(c.start_line), + }) + .collect() +} + +fn interleave_candidates( + thoughts: Vec, + candidates: Vec, + cap: usize, +) -> Vec { + if candidates.is_empty() { + return thoughts; + } + if thoughts.is_empty() { + return candidates.into_iter().take(cap).collect(); + } + let mut result = Vec::with_capacity(thoughts.len() + candidates.len()); + let mut ci = 0; + for (i, t) in thoughts.into_iter().enumerate() { + result.push(t); + if (i + 1) % 3 == 0 && ci < candidates.len() { + result.push(candidates[ci].clone()); + ci += 1; + } + } + while ci < candidates.len() && result.len() < cap { + result.push(candidates[ci].clone()); + ci += 1; + } + result.truncate(cap); + result +} + fn list_review_queue_blocking(canonical_root: &Path) -> Result { let (entries, meta) = thought_retrieval::enumerate_vault_thought_entries_blocking(canonical_root)?; @@ -635,7 +721,7 @@ fn list_review_queue_blocking(canonical_root: &Path) -> Result today { @@ -647,7 +733,16 @@ fn list_review_queue_blocking(canonical_root: &Path) -> Result Result = eligible + let thought_items: Vec = eligible .into_iter() .take(cap) .map(|(overdue_days, next_due, e)| ReviewQueueItem { @@ -698,9 +793,17 @@ fn list_review_queue_blocking(canonical_root: &Path) -> Result, pass_count: u32) -> Opt parse_meta_date(created) } -/// `completed_pass_count` 为当前 `challenge_pass_count`;下一到期日 = 锚点 + 间隔[`min(count,4)`]。 -fn next_due_after_anchor(anchor: NaiveDate, completed_pass_count: u32) -> Option { - let idx = (completed_pass_count as usize).min(REVIEW_INTERVALS_DAYS.len() - 1); - let days = REVIEW_INTERVALS_DAYS[idx]; +/// Next due date = anchor + SM-2 interval (or legacy fallback for un-migrated thoughts). +fn next_due_after_anchor(anchor: NaiveDate, entry: &thought_retrieval::VaultThoughtEntry) -> Option { + let days = if let Some(iv) = entry.srs_interval_days { + iv.round().max(1.0) as i64 + } else { + let idx = (entry.challenge_pass_count as usize).min(_LEGACY_REVIEW_INTERVALS_DAYS.len() - 1); + _LEGACY_REVIEW_INTERVALS_DAYS[idx] + }; anchor.checked_add_signed(Duration::days(days)) } @@ -759,6 +866,16 @@ pub struct ReviewQueueItem { /// 已相对 `next_due_at` 过期的日历天数(越大越优先) pub overdue_days: i64, pub private_omitted: bool, + /// "thought" | "candidate" + pub source_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub marking_reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub paired_excerpt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_line: Option, } #[derive(Debug, Clone, Serialize)] @@ -819,24 +936,50 @@ mod tests { assert!(!g.skipped); } + fn make_entry(pass_count: u32, ef: Option, iv: Option) -> thought_retrieval::VaultThoughtEntry { + thought_retrieval::VaultThoughtEntry { + rel_path: "test.md".to_string(), + thought_id: "t1".to_string(), + excerpt: String::new(), + maturity: thought_parser::ThoughtMaturity::Seedling, + created: "2026-01-01T00:00:00Z".to_string(), + last_reviewed_at: None, + challenge_pass_count: pass_count, + temporary: false, + private_omitted: false, + srs_easiness_factor: ef, + srs_interval_days: iv, + } + } + #[test] fn next_due_first_review_one_day_after_created() { let created = "2026-01-01T00:00:00Z"; let anchor = review_anchor_date(created, None, 0).unwrap(); assert_eq!(anchor, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()); - let next = next_due_after_anchor(anchor, 0).unwrap(); + let entry = make_entry(0, None, None); + let next = next_due_after_anchor(anchor, &entry).unwrap(); assert_eq!(next, NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()); } #[test] - fn next_due_after_one_pass_uses_three_day_gap() { + fn next_due_legacy_after_one_pass_uses_three_day_gap() { let last = "2026-04-10"; let anchor = review_anchor_date("2026-01-01T00:00:00Z", Some(last), 1).unwrap(); assert_eq!(anchor, NaiveDate::from_ymd_opt(2026, 4, 10).unwrap()); - let next = next_due_after_anchor(anchor, 1).unwrap(); + let entry = make_entry(1, None, None); + let next = next_due_after_anchor(anchor, &entry).unwrap(); assert_eq!(next, NaiveDate::from_ymd_opt(2026, 4, 13).unwrap()); } + #[test] + fn next_due_sm2_uses_srs_interval() { + let anchor = NaiveDate::from_ymd_opt(2026, 5, 1).unwrap(); + let entry = make_entry(2, Some(2.5), Some(15.0)); + let next = next_due_after_anchor(anchor, &entry).unwrap(); + assert_eq!(next, NaiveDate::from_ymd_opt(2026, 5, 16).unwrap()); + } + #[test] fn prune_review_deferred_until_drops_released_ids() { let today = NaiveDate::from_ymd_opt(2026, 4, 22).unwrap(); diff --git a/src-tauri/src/cognitive_push.rs b/src-tauri/src/cognitive_push.rs new file mode 100644 index 0000000..05240b7 --- /dev/null +++ b/src-tauri/src/cognitive_push.rs @@ -0,0 +1,287 @@ +//! 认知回顾桌面推送:基于 CognitiveReportForUi 生成紧凑摘要,驱动 OS 通知。 +//! 仅在应用运行时触发(启动检查 + 30 分钟定期检查)。 + +use crate::cognitive_report::{self, CognitiveReportForUi}; +use crate::vault_config::CognitiveConfig; +use chrono::{Datelike, Local, NaiveDate}; +use serde::Serialize; +use std::path::Path; + +/// 推送通知内容 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PushSummary { + pub title: String, + pub body: String, +} + +/// 判定是否需要发送周报 +fn should_send_weekly(last_sent: Option<&NaiveDate>) -> bool { + let now = Local::now().date_naive(); + match last_sent { + None => true, + Some(last) => (now - *last).num_days() >= 6, + } +} + +/// 判定是否需要发送月报 +fn should_send_monthly(last_sent: Option<&NaiveDate>) -> bool { + let now = Local::now().date_naive(); + match last_sent { + None => true, + Some(last) => last.month() != now.month() || last.year() != now.year(), + } +} + +/// 生成周报摘要;无活动返回 None(不推送) +fn build_weekly_summary(report: &CognitiveReportForUi) -> Option { + let activity = report.updated_this_month + report.new_this_month; + if activity == 0 { + return None; + } + + let title = "本周认知回顾".to_string(); + + // 从 timelines 中提取最活跃的 thought + let top_thought = report.timelines.first().map(|t| { + let excerpt: String = t.excerpt.chars().take(30).collect(); + let history_count = t.history.len(); + format!("「{excerpt}」经历 {history_count} 次变化") + }); + + let mut body_parts = vec![format!("本月活跃 {activity} 个想法")]; + + // 成熟度分布 + let total = report.maturity.seedling + report.maturity.growing + report.maturity.mature; + if total > 0 { + body_parts.push(format!( + "🌱{} 🌿{} 🌳{}", + report.maturity.seedling, report.maturity.growing, report.maturity.mature + )); + } + + if let Some(thought) = top_thought { + body_parts.push(thought); + } + + Some(PushSummary { + title, + body: body_parts.join(" · "), + }) +} + +/// 生成月报摘要;无晋升返回鼓励文案 +fn build_monthly_summary(report: &CognitiveReportForUi) -> Option { + let title = "本月认知回顾".to_string(); + + // 计算本月 vs 上月的成熟度变化 + let (promoted_to_growing, promoted_to_mature) = + if let Some(ref prev) = report.prev_month_maturity { + let to_growing = report.maturity.growing.saturating_sub(prev.growing); + let to_mature = report.maturity.mature.saturating_sub(prev.mature); + (to_growing, to_mature) + } else { + (0, 0) + }; + + let total_promotions = promoted_to_growing + promoted_to_mature; + + if total_promotions == 0 && report.new_this_month == 0 { + // 无晋升也无新增 → 鼓励文案 + let total = report.maturity.seedling + report.maturity.growing + report.maturity.mature; + if total == 0 { + return None; + } + return Some(PushSummary { + title, + body: format!("本月复习了 {total} 个想法,继续坚持!"), + }); + } + + let mut body_parts = vec![]; + + if total_promotions > 0 { + let mut promotion_desc = vec![]; + if promoted_to_growing > 0 { + promotion_desc.push(format!("{promoted_to_growing} 个🌱→🌿")); + } + if promoted_to_mature > 0 { + promotion_desc.push(format!("{promoted_to_mature} 个🌿→🌳")); + } + body_parts.push(format!("{} 理解加深", promotion_desc.join(","))); + } + + if report.new_this_month > 0 { + body_parts.push(format!("新增 {} 个想法", report.new_this_month)); + } + + // 成长最快的 thought + if let Some(ref top) = report.timelines.first() { + let excerpt: String = top.excerpt.chars().take(25).collect(); + body_parts.push(format!("成长最快:「{excerpt}」")); + } + + Some(PushSummary { + title, + body: body_parts.join(" · "), + }) +} + +/// 检查是否需要推送,返回待发送的通知列表 +pub fn check_and_build_notifications(root: &Path, config: &CognitiveConfig) -> Vec { + if !config.cognitive_push_enabled { + return vec![]; + } + + let report = match cognitive_report::generate_cognitive_report_blocking(root) { + Ok(r) => r, + Err(_) => return vec![], + }; + + let last_sent = config + .cognitive_push_last_sent + .as_ref() + .and_then(|s| NaiveDate::parse_from_str(&s[..10], "%Y-%m-%d").ok()); + + let mut notifications = vec![]; + + // 周报判定 + if matches!(config.cognitive_push_frequency.as_str(), "weekly" | "both") { + if should_send_weekly(last_sent.as_ref()) { + if let Some(summary) = build_weekly_summary(&report) { + notifications.push(summary); + } + } + } + + // 月报判定 + if matches!(config.cognitive_push_frequency.as_str(), "monthly" | "both") { + if should_send_monthly(last_sent.as_ref()) { + if let Some(summary) = build_monthly_summary(&report) { + notifications.push(summary); + } + } + } + + notifications +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_send_weekly_no_last_sent() { + assert!(should_send_weekly(None)); + } + + #[test] + fn test_should_send_weekly_recent() { + let now = Local::now().date_naive(); + assert!(!should_send_weekly(Some(&now))); + } + + #[test] + fn test_should_send_weekly_old() { + let now = Local::now().date_naive(); + let old = now - chrono::Duration::days(7); + assert!(should_send_weekly(Some(&old))); + } + + #[test] + fn test_should_send_monthly_same_month() { + let now = Local::now().date_naive(); + assert!(!should_send_monthly(Some(&now))); + } + + #[test] + fn test_should_send_monthly_different_month() { + let now = Local::now().date_naive(); + let old = now - chrono::Duration::days(35); + assert!(should_send_monthly(Some(&old))); + } + + #[test] + fn test_build_weekly_summary_no_activity() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 0, + updated_this_month: 0, + maturity: Default::default(), + prev_month_maturity: None, + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + assert!(build_weekly_summary(&report).is_none()); + } + + #[test] + fn test_build_weekly_summary_with_activity() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 2, + updated_this_month: 3, + maturity: Default::default(), + prev_month_maturity: None, + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + let summary = build_weekly_summary(&report).unwrap(); + assert!(summary.body.contains("5")); + } + + #[test] + fn test_build_monthly_summary_no_promotions() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 0, + updated_this_month: 0, + maturity: crate::cognitive_report::MaturityCounts { + seedling: 3, + growing: 1, + mature: 1, + }, + prev_month_maturity: Some(crate::cognitive_report::MaturityCounts { + seedling: 3, + growing: 1, + mature: 1, + }), + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + let summary = build_monthly_summary(&report).unwrap(); + assert!(summary.body.contains("坚持")); + } + + #[test] + fn test_build_monthly_summary_with_promotions() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 1, + updated_this_month: 2, + maturity: crate::cognitive_report::MaturityCounts { + seedling: 2, + growing: 2, + mature: 1, + }, + prev_month_maturity: Some(crate::cognitive_report::MaturityCounts { + seedling: 3, + growing: 1, + mature: 1, + }), + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + let summary = build_monthly_summary(&report).unwrap(); + assert!(summary.body.contains("🌿")); + assert!(summary.body.contains("1")); + } +} diff --git a/src-tauri/src/cognitive_report.rs b/src-tauri/src/cognitive_report.rs index acd4702..dedf6e9 100644 --- a/src-tauri/src/cognitive_report.rs +++ b/src-tauri/src/cognitive_report.rs @@ -40,6 +40,15 @@ pub struct TimelineThoughtOut { pub history: Vec, } +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MonthlySnapshot { + pub year_month: String, + pub seedling: usize, + pub growing: usize, + pub mature: usize, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CognitiveReportForUi { @@ -51,6 +60,7 @@ pub struct CognitiveReportForUi { pub prev_month_maturity: Option, pub total_ai_references: usize, pub timelines: Vec, + pub monthly_snapshots: Vec, } #[derive(Deserialize, Serialize, Default, Clone)] @@ -242,6 +252,20 @@ pub fn generate_cognitive_report_blocking(root: &Path) -> Result = snap + .months + .iter() + .rev() + .take(6) + .rev() + .map(|m| MonthlySnapshot { + year_month: m.year_month.clone(), + seedling: m.seedling, + growing: m.growing, + mature: m.mature, + }) + .collect(); + Ok(CognitiveReportForUi { scanned_files, total_thoughts, @@ -251,6 +275,7 @@ pub fn generate_cognitive_report_blocking(root: &Path) -> Result, + pub total_challenges: usize, + pub total_days: usize, + pub pass_rate: f64, +} + +/// 成长旅程中的单个里程碑 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JourneyMilestone { + pub date: String, + pub event_type: String, + pub description: String, +} + +/// 从 Markdown 文件中查找指定 thought 的元数据 +fn find_thought_meta(root: &Path, thought_id: &str) -> Result, String> { + let mut paths: Vec = Vec::new(); + vault_context_search::walk_markdown_files(root, root, &mut paths, 600)?; + + for abs in &paths { + let Some(rel) = vault_context_search::rel_path_from_root(root, abs) else { + continue; + }; + let bytes = std::fs::read(abs).map_err(|e| format!("reading {rel}: {e}"))?; + if bytes.len() > 512 * 1024 { + continue; + } + let Ok(text) = String::from_utf8(bytes) else { + continue; + }; + if note_privacy::markdown_treat_as_kf_private(&text) { + continue; + } + if !text.contains("kf-thoughts") { + continue; + } + let parsed = thought_parser::parse_note_thoughts_for_workspace(root, &rel, &text); + for meta in &parsed.meta { + if meta.id == thought_id { + return Ok(Some((meta.clone(), rel, text))); + } + } + } + Ok(None) +} + +/// 构建成长故事 +pub fn build_growth_story(root: &Path, thought_id: &str) -> Result { + let (meta, rel_path, _markdown) = find_thought_meta(root, thought_id)? + .ok_or_else(|| format!("thought {thought_id} not found"))?; + + let title = extract_title_from_body(&meta, root, &rel_path); + let content_preview = extract_content_preview(&meta, root, &rel_path); + + let maturity_str = match meta.maturity { + ThoughtMaturity::Seedling => "seedling", + ThoughtMaturity::Growing => "growing", + ThoughtMaturity::Mature => "mature", + }; + + let mut journey = Vec::new(); + + // created 事件 + journey.push(JourneyMilestone { + date: format_date_short(&meta.created), + event_type: "created".to_string(), + description: "开始追踪这个想法".to_string(), + }); + + // history 事件 + let mut challenge_count = 0usize; + let mut pass_count = 0usize; + + for entry in &meta.history { + let desc = match entry.entry_type.as_str() { + "created" => continue, // 已添加 + "substantial-change" => { + entry.diff_summary.clone().unwrap_or_else(|| "内容更新".to_string()) + } + "challenge-review-pass" => { + challenge_count += 1; + pass_count += 1; + format!("第 {} 次挑战通过", challenge_count) + } + "challenge-review-attempt" => { + challenge_count += 1; + format!("第 {} 次挑战尝试", challenge_count) + } + _ => entry.diff_summary.clone().unwrap_or_else(|| "事件".to_string()), + }; + journey.push(JourneyMilestone { + date: format_date_short(&entry.date), + event_type: entry.entry_type.clone(), + description: desc, + }); + } + + // 成熟度晋升事件(从 history 中的 challenge-review-pass 推断) + if meta.maturity != ThoughtMaturity::Seedling { + // 检查是否有晋升事件(通过 pass_count 推断) + if meta.challenge_pass_count >= 1 && meta.maturity as u8 >= ThoughtMaturity::Growing as u8 { + // 找到第一个 challenge-review-pass 的日期作为晋升到 Growing 的时间 + if let Some(first_pass) = meta.history.iter().find(|h| h.entry_type == "challenge-review-pass") { + journey.push(JourneyMilestone { + date: format_date_short(&first_pass.date), + event_type: "promoted".to_string(), + description: "🌱→🌿 理解加深".to_string(), + }); + } + } + if meta.maturity == ThoughtMaturity::Mature { + // 找到最后一个 challenge-review-pass 的日期作为晋升到 Mature 的时间 + if let Some(last_pass) = meta.history.iter().rev().find(|h| h.entry_type == "challenge-review-pass") { + journey.push(JourneyMilestone { + date: format_date_short(&last_pass.date), + event_type: "promoted".to_string(), + description: "🌿🌳 融会贯通".to_string(), + }); + } + } + } + + // 按日期排序 + journey.sort_by(|a, b| a.date.cmp(&b.date)); + + // 计算总天数 + let total_days = compute_total_days(&meta.created); + + // 计算通过率 + let pass_rate = if challenge_count > 0 { + pass_count as f64 / challenge_count as f64 + } else { + 0.0 + }; + + Ok(GrowthStory { + thought_id: meta.id, + thought_title: title, + content_preview, + source_file: rel_path, + created_at: meta.created, + current_maturity: maturity_str.to_string(), + journey, + total_challenges: challenge_count, + total_days, + pass_rate, + }) +} + +/// 从 thought body 中提取标题(第一行或前 50 字符) +fn extract_title_from_body(meta: &KfThoughtMeta, root: &Path, rel_path: &str) -> String { + let body = read_thought_body(root, rel_path, &meta.id); + if body.is_empty() { + return meta.id.clone(); + } + let first_line = body.lines().next().unwrap_or("").trim(); + if first_line.is_empty() { + meta.id.clone() + } else if first_line.len() > 50 { + format!("{}…", &first_line[..50]) + } else { + first_line.to_string() + } +} + +/// 提取内容预览(前 100 字符) +fn extract_content_preview(meta: &KfThoughtMeta, root: &Path, rel_path: &str) -> String { + let body = read_thought_body(root, rel_path, &meta.id); + if body.is_empty() { + return String::new(); + } + let preview: String = body.chars().take(100).collect(); + if body.len() > 100 { + format!("{preview}…") + } else { + preview + } +} + +/// 从 SQLite 读取 thought body +fn read_thought_body(root: &Path, _rel_path: &str, thought_id: &str) -> String { + let conn = match crate::vault_thoughts_db::open_thoughts_db(root) { + Ok(c) => c, + Err(_) => return String::new(), + }; + crate::vault_thoughts_db::get_body(&conn, thought_id) + .ok() + .flatten() + .unwrap_or_default() +} + +/// 格式化日期为短格式 "M/D" +fn format_date_short(rfc3339: &str) -> String { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) { + let local = dt.with_timezone(&chrono::Local); + format!("{}/{}", local.month(), local.day()) + } else if rfc3339.len() >= 10 { + // 尝试 YYYY-MM-DD 格式 + NaiveDate::parse_from_str(&rfc3339[..10], "%Y-%m-%d") + .map(|d| format!("{}/{}", d.month(), d.day())) + .unwrap_or_else(|_| rfc3339[..10].to_string()) + } else { + rfc3339.to_string() + } +} + +/// 计算从创建到现在的天数 +fn compute_total_days(created_at: &str) -> usize { + let created = chrono::DateTime::parse_from_rfc3339(created_at) + .ok() + .map(|dt| dt.date_naive()); + let now = Utc::now().date_naive(); + match created { + Some(c) => (now - c).num_days().max(0) as usize, + None => 0, + } +} + +/// 生成 HTML 卡片格式的成长故事(用于图片导出) +pub fn to_html_card(story: &GrowthStory) -> String { + let maturity_emoji = match story.current_maturity.as_str() { + "seedling" => "🌱", + "growing" => "🌿", + "mature" => "🌳", + _ => "🌱", + }; + let maturity_label = match story.current_maturity.as_str() { + "seedling" => "萌芽", + "growing" => "成长", + "mature" => "融会贯通", + _ => "萌芽", + }; + + let journey_html: String = story + .journey + .iter() + .map(|m| { + let icon = match m.event_type.as_str() { + "created" => "💡", + "substantial-change" => "✏️", + "challenge-review-pass" => "✅", + "challenge-review-attempt" => "🔄", + "promoted" => "⬆️", + _ => "📌", + }; + format!( + r#"
{icon}{date}{desc}
"#, + icon = icon, + date = m.date, + desc = m.description + ) + }) + .collect(); + + format!( + r#" + + + + + + +
+
+
{emoji}
+
{title}
+
{label}
+
+
+ {journey} +
+
+ 经历 {challenges} 次挑战 · 通过率 {pass_rate:.0}% · 历时 {days} 天 +
+ +
+ +"#, + emoji = maturity_emoji, + title = story.thought_title, + label = maturity_label, + journey = journey_html, + challenges = story.total_challenges, + pass_rate = story.pass_rate * 100.0, + days = story.total_days + ) +} + +/// 生成 Markdown 格式的成长故事(used in tests; frontend generates its own Markdown export) +#[allow(dead_code)] +pub fn to_markdown(story: &GrowthStory) -> String { + let maturity_emoji = match story.current_maturity.as_str() { + "seedling" => "🌱", + "growing" => "🌿", + "mature" => "🌳", + _ => "🌱", + }; + let maturity_label = match story.current_maturity.as_str() { + "seedling" => "萌芽", + "growing" => "成长", + "mature" => "融会贯通", + _ => "萌芽", + }; + + let mut md = format!("## {} {} — 成长故事({})\n\n", maturity_emoji, story.thought_title, maturity_label); + + if !story.content_preview.is_empty() { + md.push_str(&format!("> {}\n\n", story.content_preview)); + } + + md.push_str(&format!( + "从 {} 开始追踪,历时 {} 天:\n\n", + format_date_short(&story.created_at), + story.total_days + )); + + for m in &story.journey { + md.push_str(&format!("- {} {}\n", m.date, m.description)); + } + + md.push_str(&format!( + "\n共经历 {} 次挑战 · 通过率 {:.0}% · 历时 {} 天\n", + story.total_challenges, + story.pass_rate * 100.0, + story.total_days + )); + + md.push_str("\n---\n*Generated by KnowForge*\n"); + + md +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_date_short() { + assert_eq!(format_date_short("2026-07-15T10:30:00+08:00"), "7/15"); + } + + #[test] + fn test_format_date_short_plain() { + assert_eq!(format_date_short("2026-01-05"), "1/5"); + } + + #[test] + fn test_compute_total_days() { + let now = Utc::now(); + let created = (now - chrono::Duration::days(10)).to_rfc3339(); + let days = compute_total_days(&created); + assert!((9..=11).contains(&days)); + } + + #[test] + fn test_to_markdown() { + let story = GrowthStory { + thought_id: "test".to_string(), + thought_title: "Test Thought".to_string(), + content_preview: "Preview".to_string(), + source_file: "test.md".to_string(), + created_at: "2026-07-01T00:00:00+00:00".to_string(), + current_maturity: "growing".to_string(), + journey: vec![ + JourneyMilestone { + date: "7/1".to_string(), + event_type: "created".to_string(), + description: "开始追踪".to_string(), + }, + JourneyMilestone { + date: "7/8".to_string(), + event_type: "challenge-review-pass".to_string(), + description: "第 1 次挑战通过".to_string(), + }, + ], + total_challenges: 3, + total_days: 15, + pass_rate: 0.6667, + }; + let md = to_markdown(&story); + assert!(md.contains("成长故事")); + assert!(md.contains("Test Thought")); + assert!(md.contains("7/1")); + assert!(md.contains("7/8")); + assert!(md.contains("KnowForge")); + } +} diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs new file mode 100644 index 0000000..4258f7a --- /dev/null +++ b/src-tauri/src/latent_paragraphs.rs @@ -0,0 +1,1101 @@ +use rusqlite::{params, Connection}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use crate::note_privacy; +use crate::semantic_index::{cosine_similarity, DocChunkRow, EmbeddingCache}; + +const THRESHOLD_HIGH_SIM: f32 = 0.85; +const THRESHOLD_ISOLATED: f32 = 0.3; +const THRESHOLD_CLUSTER: f32 = 0.75; +const MAX_SCAN_CHUNKS: usize = 20_000; +const MAX_CANDIDATES: usize = 500; +const MIN_CHUNK_CHARS: usize = 50; +const EXCERPT_LEN: usize = 200; + +/// Bump this when filter logic changes to invalidate cached candidates. +const FILTER_VERSION: i64 = 2; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CandidateForUi { + pub id: String, + pub rel_path: String, + pub excerpt: String, + pub marking_reason: String, + pub similarity_score: Option, + pub paired_rel_path: Option, + pub start_line: i32, + pub end_line: i32, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct ScanResult { + pub total_chunks_scanned: usize, + pub candidates_found: usize, +} + +#[derive(Debug, Clone)] +struct RawCandidate { + chunk_idx: usize, + marking_reason: &'static str, + similarity_score: Option, + paired_rel_path: Option, +} + +// --------------------------------------------------------------------------- +// Schema +// --------------------------------------------------------------------------- + +pub fn init_candidates_schema(conn: &Connection) -> Result<(), String> { + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS thought_candidates ( + id TEXT PRIMARY KEY, + rel_path TEXT NOT NULL, + chunk_id TEXT NOT NULL, + paragraph_start_line INTEGER NOT NULL, + paragraph_end_line INTEGER NOT NULL, + paragraph_hash TEXT NOT NULL, + marking_reason TEXT NOT NULL, + similarity_score REAL, + paired_rel_path TEXT, + created_at TEXT NOT NULL, + dismissed_at TEXT, + promoted_thought_id TEXT + ); + CREATE INDEX IF NOT EXISTS idx_tc_rel_path ON thought_candidates(rel_path); + CREATE INDEX IF NOT EXISTS idx_tc_reason ON thought_candidates(marking_reason); + CREATE INDEX IF NOT EXISTS idx_tc_chunk_id ON thought_candidates(chunk_id); + CREATE TABLE IF NOT EXISTS latent_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + "#, + ) + .map_err(|e| format!("init thought_candidates schema: {e}"))?; + Ok(()) +} + +/// Check if the stored filter version matches the current FILTER_VERSION. +/// If outdated, clear all non-dismissed/non-promoted candidates so a fresh scan runs. +pub fn invalidate_if_filter_changed(conn: &Connection) -> Result { + let stored: i64 = conn + .query_row( + "SELECT CAST(value AS INTEGER) FROM latent_meta WHERE key = 'filter_version'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + if stored == FILTER_VERSION { + return Ok(false); + } + conn.execute( + "DELETE FROM thought_candidates WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL", + [], + ) + .map_err(|e| format!("clear outdated candidates: {e}"))?; + conn.execute( + "INSERT OR REPLACE INTO latent_meta (key, value) VALUES ('filter_version', ?1)", + params![FILTER_VERSION.to_string()], + ) + .map_err(|e| format!("update filter_version: {e}"))?; + eprintln!("[latent_paragraphs] filter version changed ({stored} → {FILTER_VERSION}), cleared old candidates"); + Ok(true) +} + +// --------------------------------------------------------------------------- +// Heuristic filters +// --------------------------------------------------------------------------- + +pub fn should_skip_chunk(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.chars().count() < MIN_CHUNK_CHARS { + return true; + } + if is_pure_list(trimmed) { + return true; + } + if is_code_block(trimmed) { + return true; + } + if is_code_heavy(trimmed) { + return true; + } + if is_quote_block(trimmed) { + return true; + } + if is_table_heavy(trimmed) { + return true; + } + if is_frontmatter(trimmed) { + return true; + } + if is_heading_only(trimmed) { + return true; + } + false +} + +fn is_pure_list(text: &str) -> bool { + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + return false; + } + lines.iter().all(|line| { + let t = line.trim_start(); + t.starts_with("- ") + || t.starts_with("* ") + || t.starts_with("+ ") + || t.chars() + .take_while(|c| c.is_ascii_digit()) + .count() + .gt(&0) + && (t.contains(". ") || t.contains(") ")) + }) +} + +fn is_code_block(text: &str) -> bool { + let trimmed = text.trim(); + if trimmed.starts_with("```") && trimmed.ends_with("```") && trimmed.matches("```").count() >= 2 + { + return true; + } + // Partial fenced code (split boundary) — any ``` fence present means mostly code + if trimmed.contains("```") { + return true; + } + false +} + +fn is_code_heavy(text: &str) -> bool { + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + return false; + } + let code_lines = lines + .iter() + .filter(|l| { + let t = l.trim_start(); + t.starts_with("```") + || l.starts_with(" ") + || l.starts_with('\t') + || looks_like_code(t) + }) + .count(); + code_lines * 100 / lines.len() > 60 +} + +fn looks_like_code(line: &str) -> bool { + let indicators = [ + "def ", "fn ", "func ", "class ", "import ", "from ", "return ", + "if (", "if(", "for (", "for(", "while (", "while(", + "const ", "let ", "var ", "async ", "await ", + "pub ", "use ", "mod ", "struct ", "enum ", + "});", ");", "};", "} else", "} catch", + ]; + indicators.iter().any(|p| line.starts_with(p)) + || (line.ends_with(';') && !line.ends_with(";")) + || (line.ends_with('{') || line.ends_with('}')) + || (line.starts_with('#') && line.contains("include")) +} + +fn is_quote_block(text: &str) -> bool { + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + return false; + } + lines.iter().all(|line| line.trim_start().starts_with("> ")) +} + +/// Markdown table detection. Skip if any of: +/// - Contains a table separator row (e.g. `|---|---|`) +/// - > 30% of non-empty lines contain pipe `|` characters +fn is_table_heavy(text: &str) -> bool { + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.len() < 2 { + return false; + } + // Fast path: if any line looks like a table separator, it's a table + let has_separator = lines.iter().any(|l| { + let t = l.trim(); + t.contains("|") && t.contains("---") + }); + if has_separator { + return true; + } + // Slow path: count lines with pipe chars + let table_lines = lines.iter().filter(|l| l.trim().contains('|')).count(); + table_lines * 100 / lines.len() > 30 +} + +/// YAML frontmatter block: starts with `---` and ends with `---` or `...` +fn is_frontmatter(text: &str) -> bool { + let trimmed = text.trim(); + if !trimmed.starts_with("---") { + return false; + } + // Check if it ends with a closing fence + let rest = trimmed.strip_prefix("---").unwrap_or("").trim(); + rest.ends_with("---") || rest.ends_with("...") +} + +/// Pure heading lines: every non-empty line starts with `#` +fn is_heading_only(text: &str) -> bool { + let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); + if lines.is_empty() { + return false; + } + lines.iter().all(|line| line.trim_start().starts_with('#')) +} + +// --------------------------------------------------------------------------- +// Line number computation +// --------------------------------------------------------------------------- + +fn compute_line_range(file_content: &str, chunk_text: &str) -> (i32, i32) { + let search_text = strip_heading_context(chunk_text); + if let Some(byte_offset) = file_content.find(&search_text) { + let newlines_before = file_content[..byte_offset].matches('\n').count(); + let start_line = (newlines_before + 1) as i32; + let chunk_lines = search_text.lines().count().max(1) as i32; + (start_line, start_line + chunk_lines - 1) + } else { + (1, 1) + } +} + +fn strip_heading_context(text: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let mut start = 0; + for (i, line) in lines.iter().enumerate() { + if line.starts_with('#') { + start = i + 1; + while start < lines.len() && lines[start].trim().is_empty() { + start += 1; + } + break; + } + if !line.trim().is_empty() { + break; + } + } + lines[start..].join("\n") +} + +// --------------------------------------------------------------------------- +// Union-Find for cross-doc recurrence +// --------------------------------------------------------------------------- + +struct UnionFind { + parent: Vec, + rank: Vec, +} + +impl UnionFind { + fn new(n: usize) -> Self { + Self { + parent: (0..n).collect(), + rank: vec![0; n], + } + } + + fn find(&mut self, x: usize) -> usize { + if self.parent[x] != x { + self.parent[x] = self.find(self.parent[x]); + } + self.parent[x] + } + + fn union(&mut self, a: usize, b: usize) { + let ra = self.find(a); + let rb = self.find(b); + if ra == rb { + return; + } + if self.rank[ra] < self.rank[rb] { + self.parent[ra] = rb; + } else if self.rank[ra] > self.rank[rb] { + self.parent[rb] = ra; + } else { + self.parent[rb] = ra; + self.rank[ra] += 1; + } + } +} + +// --------------------------------------------------------------------------- +// Core scan +// --------------------------------------------------------------------------- + +pub fn scan_vault( + embed_conn: &Connection, + embed_cache: &EmbeddingCache, + vault_root: &Path, +) -> Result { + let all_docs = embed_cache.get_docs(embed_conn); + + let chunks = filter_chunks(&all_docs, vault_root); + let n = chunks.len(); + if n == 0 { + return Ok(ScanResult { + total_chunks_scanned: 0, + candidates_found: 0, + }); + } + + eprintln!( + "[latent_paragraphs] scan_vault: {} chunks after filtering (from {} total)", + n, + all_docs.len() + ); + + let candidates = compute_candidates(&chunks); + + let now = chrono::Utc::now().to_rfc3339(); + let inserted = persist_candidates(embed_conn, vault_root, &chunks, &candidates, &now)?; + + eprintln!("[latent_paragraphs] scan_vault: {inserted} candidates persisted"); + + Ok(ScanResult { + total_chunks_scanned: n, + candidates_found: inserted, + }) +} + +fn filter_chunks<'a>(all_docs: &'a [DocChunkRow], vault_root: &Path) -> Vec<&'a DocChunkRow> { + let mut privacy_cache: HashMap = HashMap::new(); + let mut chunks: Vec<&DocChunkRow> = Vec::new(); + + for chunk in all_docs.iter() { + let is_private = *privacy_cache + .entry(chunk.rel_path.clone()) + .or_insert_with(|| { + let full = vault_root.join(&chunk.rel_path); + note_privacy::peek_kf_private_from_md_file(&full) + }); + if is_private { + continue; + } + if should_skip_chunk(&chunk.chunk_text) { + continue; + } + chunks.push(chunk); + } + + if chunks.len() > MAX_SCAN_CHUNKS { + eprintln!( + "[latent_paragraphs] capping scan to {MAX_SCAN_CHUNKS} chunks (had {})", + chunks.len() + ); + chunks.truncate(MAX_SCAN_CHUNKS); + } + + chunks +} + +fn compute_candidates(chunks: &[&DocChunkRow]) -> Vec { + let n = chunks.len(); + let mut max_sim = vec![0.0f32; n]; + let mut high_sim_pairs: Vec<(usize, usize, f32)> = Vec::new(); + let mut uf = UnionFind::new(n); + + for i in 0..n { + for j in (i + 1)..n { + let sim = cosine_similarity(&chunks[i].embedding, &chunks[j].embedding); + + if sim > max_sim[i] { + max_sim[i] = sim; + } + if sim > max_sim[j] { + max_sim[j] = sim; + } + + let cross_doc = chunks[i].rel_path != chunks[j].rel_path; + if !cross_doc { + continue; + } + + if sim > THRESHOLD_HIGH_SIM { + high_sim_pairs.push((i, j, sim)); + } + if sim > THRESHOLD_CLUSTER { + uf.union(i, j); + } + } + } + + let mut marked: HashMap = HashMap::new(); + + // 1. High similarity pairs (highest priority) + for &(i, j, sim) in &high_sim_pairs { + marked.entry(i).or_insert(RawCandidate { + chunk_idx: i, + marking_reason: "high_similarity", + similarity_score: Some(sim as f64), + paired_rel_path: Some(chunks[j].rel_path.clone()), + }); + marked.entry(j).or_insert(RawCandidate { + chunk_idx: j, + marking_reason: "high_similarity", + similarity_score: Some(sim as f64), + paired_rel_path: Some(chunks[i].rel_path.clone()), + }); + } + + // 2. Cross-doc recurrence (connected components spanning 3+ docs) + let mut components: HashMap> = HashMap::new(); + for i in 0..n { + components.entry(uf.find(i)).or_default().push(i); + } + for (_root, members) in &components { + let doc_set: HashSet<&str> = members.iter().map(|&i| chunks[i].rel_path.as_str()).collect(); + if doc_set.len() >= 3 { + for &idx in members { + // Collect other document paths in this cluster (excluding self) + let self_path = chunks[idx].rel_path.as_str(); + let other_docs: Vec<&str> = doc_set.iter().copied().filter(|p| *p != self_path).collect(); + let paired = if other_docs.is_empty() { + None + } else { + Some(other_docs.join(",")) + }; + marked.entry(idx).or_insert(RawCandidate { + chunk_idx: idx, + marking_reason: "cross_doc_recurrence", + similarity_score: Some(max_sim[idx] as f64), + paired_rel_path: paired, + }); + } + } + } + + // 3. Semantic isolated (lowest priority) + for i in 0..n { + if max_sim[i] < THRESHOLD_ISOLATED { + marked.entry(i).or_insert(RawCandidate { + chunk_idx: i, + marking_reason: "semantic_isolated", + similarity_score: Some(max_sim[i] as f64), + paired_rel_path: None, + }); + } + } + + let mut result: Vec = marked.into_values().collect(); + result.sort_by(|a, b| { + b.similarity_score + .unwrap_or(0.0) + .partial_cmp(&a.similarity_score.unwrap_or(0.0)) + .unwrap_or(std::cmp::Ordering::Equal) + }); + result.truncate(MAX_CANDIDATES); + result +} + +fn persist_candidates( + conn: &Connection, + vault_root: &Path, + chunks: &[&DocChunkRow], + candidates: &[RawCandidate], + now: &str, +) -> Result { + // Clear old non-dismissed/non-promoted candidates + conn.execute( + "DELETE FROM thought_candidates WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL", + [], + ) + .map_err(|e| format!("clear old candidates: {e}"))?; + + let mut file_cache: HashMap = HashMap::new(); + let mut inserted = 0; + + for cand in candidates { + let chunk = chunks[cand.chunk_idx]; + let file_content = file_cache + .entry(chunk.rel_path.clone()) + .or_insert_with(|| { + let path = vault_root.join(&chunk.rel_path); + std::fs::read_to_string(&path).unwrap_or_default() + }); + + let (start_line, end_line) = compute_line_range(file_content, &chunk.chunk_text); + let hash = paragraph_hash(&chunk.chunk_text); + let id = uuid::Uuid::new_v4().to_string(); + + conn.execute( + "INSERT OR REPLACE INTO thought_candidates \ + (id, rel_path, chunk_id, paragraph_start_line, paragraph_end_line, \ + paragraph_hash, marking_reason, similarity_score, paired_rel_path, \ + created_at, dismissed_at, promoted_thought_id) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, NULL)", + params![ + id, + chunk.rel_path, + chunk.chunk_id, + start_line, + end_line, + hash, + cand.marking_reason, + cand.similarity_score, + cand.paired_rel_path, + now, + ], + ) + .map_err(|e| format!("insert candidate: {e}"))?; + inserted += 1; + } + + Ok(inserted) +} + +fn paragraph_hash(text: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(text.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +fn excerpt(text: &str) -> String { + let chars: Vec = text.chars().collect(); + if chars.len() <= EXCERPT_LEN { + text.to_string() + } else { + let mut s: String = chars[..EXCERPT_LEN].iter().collect(); + s.push_str("…"); + s + } +} + +// --------------------------------------------------------------------------- +// Incremental scan for a single note +// --------------------------------------------------------------------------- + +pub fn incremental_scan_for_note( + embed_conn: &Connection, + embed_cache: &EmbeddingCache, + vault_root: &Path, + rel_path: &str, +) -> Result<(), String> { + let full_path = vault_root.join(rel_path); + if note_privacy::peek_kf_private_from_md_file(&full_path) { + conn_delete_candidates_for_path(embed_conn, rel_path)?; + return Ok(()); + } + + let all_docs = embed_cache.get_docs(embed_conn); + + let my_chunks: Vec<&DocChunkRow> = all_docs + .iter() + .filter(|c| c.rel_path == rel_path && !should_skip_chunk(&c.chunk_text)) + .collect(); + let other_chunks: Vec<&DocChunkRow> = all_docs + .iter() + .filter(|c| c.rel_path != rel_path && !should_skip_chunk(&c.chunk_text)) + .collect(); + + if my_chunks.is_empty() { + conn_delete_candidates_for_path(embed_conn, rel_path)?; + return Ok(()); + } + + // Delete old undismissed candidates for this path + conn_delete_candidates_for_path(embed_conn, rel_path)?; + + let file_content = std::fs::read_to_string(&full_path).unwrap_or_default(); + let now = chrono::Utc::now().to_rfc3339(); + let mut inserted = 0; + + for my_chunk in &my_chunks { + let mut max_sim: f32 = 0.0; + let mut best_cross_doc_sim: f32 = 0.0; + let mut best_cross_doc_path: Option = None; + let mut cross_doc_high_sim = false; + + for other in &other_chunks { + let sim = cosine_similarity(&my_chunk.embedding, &other.embedding); + if sim > max_sim { + max_sim = sim; + } + if sim > best_cross_doc_sim { + best_cross_doc_sim = sim; + best_cross_doc_path = Some(other.rel_path.clone()); + } + if sim > THRESHOLD_HIGH_SIM { + cross_doc_high_sim = true; + } + } + + let reason = if cross_doc_high_sim { + Some(("high_similarity", best_cross_doc_sim, best_cross_doc_path.clone())) + } else if max_sim < THRESHOLD_ISOLATED { + Some(("semantic_isolated", max_sim, None)) + } else { + None + }; + + if let Some((reason_str, score, paired)) = reason { + let (start_line, end_line) = + compute_line_range(&file_content, &my_chunk.chunk_text); + let hash = paragraph_hash(&my_chunk.chunk_text); + let id = uuid::Uuid::new_v4().to_string(); + + embed_conn + .execute( + "INSERT OR REPLACE INTO thought_candidates \ + (id, rel_path, chunk_id, paragraph_start_line, paragraph_end_line, \ + paragraph_hash, marking_reason, similarity_score, paired_rel_path, \ + created_at, dismissed_at, promoted_thought_id) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, NULL)", + params![ + id, + rel_path, + my_chunk.chunk_id, + start_line, + end_line, + hash, + reason_str, + Some(score as f64), + paired, + now, + ], + ) + .map_err(|e| format!("insert incremental candidate: {e}"))?; + inserted += 1; + } + } + + eprintln!( + "[latent_paragraphs] incremental_scan for {rel_path}: {inserted} candidates" + ); + Ok(()) +} + +fn conn_delete_candidates_for_path(conn: &Connection, rel_path: &str) -> Result<(), String> { + conn.execute( + "DELETE FROM thought_candidates WHERE rel_path = ?1 AND dismissed_at IS NULL AND promoted_thought_id IS NULL", + params![rel_path], + ) + .map_err(|e| format!("delete candidates for path: {e}"))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Query & operations +// --------------------------------------------------------------------------- + +pub fn list_candidates( + conn: &Connection, + limit: usize, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT tc.id, tc.rel_path, tc.paragraph_start_line, tc.paragraph_end_line, + tc.marking_reason, tc.similarity_score, tc.paired_rel_path, tc.chunk_id + FROM thought_candidates tc + WHERE tc.dismissed_at IS NULL AND tc.promoted_thought_id IS NULL + ORDER BY tc.similarity_score DESC + LIMIT ?1", + ) + .map_err(|e| format!("prepare list candidates: {e}"))?; + + let rows = stmt + .query_map(params![limit as i64], |row| { + let chunk_id: String = row.get(7)?; + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i32>(2)?, + row.get::<_, i32>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + chunk_id, + )) + }) + .map_err(|e| format!("query candidates: {e}"))?; + + let mut result = Vec::new(); + for row in rows { + let (id, rel_path, start_line, end_line, reason, score, paired, chunk_id) = + row.map_err(|e| format!("read candidate row: {e}"))?; + + let chunk_text: String = conn + .query_row( + "SELECT chunk_text FROM doc_chunks WHERE chunk_id = ?1", + params![chunk_id], + |r| r.get(0), + ) + .unwrap_or_default(); + + result.push(CandidateForUi { + id, + rel_path, + excerpt: excerpt(&chunk_text), + marking_reason: reason, + similarity_score: score, + paired_rel_path: paired, + start_line, + end_line, + }); + } + + Ok(result) +} + +pub fn dismiss_candidate(conn: &Connection, id: &str) -> Result<(), String> { + let now = chrono::Utc::now().to_rfc3339(); + conn.execute( + "UPDATE thought_candidates SET dismissed_at = ?1 WHERE id = ?2", + params![now, id], + ) + .map_err(|e| format!("dismiss candidate: {e}"))?; + Ok(()) +} + +pub fn get_candidate_chunk_text( + conn: &Connection, + candidate_id: &str, +) -> Result<(String, CandidateForUi), String> { + let row = conn + .query_row( + "SELECT tc.id, tc.rel_path, tc.paragraph_start_line, tc.paragraph_end_line, + tc.marking_reason, tc.similarity_score, tc.paired_rel_path, tc.chunk_id + FROM thought_candidates tc WHERE tc.id = ?1", + params![candidate_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i32>(2)?, + row.get::<_, i32>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, String>(7)?, + )) + }, + ) + .map_err(|e| format!("get candidate: {e}"))?; + let (id, rel_path, start_line, end_line, reason, score, paired, chunk_id) = row; + let chunk_text: String = conn + .query_row( + "SELECT chunk_text FROM doc_chunks WHERE chunk_id = ?1", + params![chunk_id], + |r| r.get(0), + ) + .map_err(|e| format!("get chunk text: {e}"))?; + let candidate = CandidateForUi { + id, + rel_path, + excerpt: excerpt(&chunk_text), + marking_reason: reason, + similarity_score: score, + paired_rel_path: paired, + start_line, + end_line, + }; + Ok((chunk_text, candidate)) +} + +pub fn promote_candidate( + embed_conn: &Connection, + canonical_root: &std::path::Path, + candidate_id: &str, +) -> Result { + let (chunk_text, candidate) = get_candidate_chunk_text(embed_conn, candidate_id)?; + + let abs_path = canonical_root.join(&candidate.rel_path); + if !abs_path.exists() { + return Err(format!("source file not found: {}", candidate.rel_path)); + } + let existing = std::fs::read_to_string(&abs_path) + .map_err(|e| format!("read source file: {e}"))?; + + let parsed = crate::thought_parser::parse_note_thoughts_for_workspace( + canonical_root, + &candidate.rel_path, + &existing, + ); + let count = parsed.meta.len().max(parsed.blocks.len()); + + let (new_markdown, resp) = crate::thought_parser::insert_thought_into_markdown( + canonical_root, + &candidate.rel_path, + &existing, + &chunk_text, + false, + Some(candidate.start_line as usize), + count, + )?; + + std::fs::write(&abs_path, &new_markdown) + .map_err(|e| format!("write updated note: {e}"))?; + + embed_conn + .execute( + "UPDATE thought_candidates SET promoted_thought_id = ?1 WHERE id = ?2", + params![resp.thought_id, candidate_id], + ) + .map_err(|e| format!("update promoted_thought_id: {e}"))?; + + Ok(resp.thought_id) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_skip_short_text() { + assert!(should_skip_chunk("hi")); + assert!(should_skip_chunk(" ")); + assert!(should_skip_chunk("")); + } + + #[test] + fn test_should_skip_list() { + let list = "- item one\n- item two\n- item three\n- item four and some more text"; + assert!(should_skip_chunk(list)); + + let numbered = "1. first thing\n2. second thing\n3. third thing here"; + assert!(should_skip_chunk(numbered)); + } + + #[test] + fn test_should_not_skip_prose() { + let prose = "This is a paragraph with enough text to be considered meaningful content for analysis and review purposes."; + assert!(!should_skip_chunk(prose)); + } + + #[test] + fn test_should_skip_code_block() { + let code = "```rust\nfn main() {\n println!(\"hello\");\n}\n```"; + assert!(should_skip_chunk(code)); + + // Partial fenced code (split boundary — only opening fence) + let partial = "```python\nfrom langgraph.graph import StateGraph, END\ndef build_agent_graph(llm):"; + assert!(should_skip_chunk(partial)); + + // Code-heavy content without fences + let code_heavy = "def build_agent():\n llm = get_llm()\n return llm.run()\n\ndef main():\n agent = build_agent()"; + assert!(should_skip_chunk(code_heavy)); + } + + #[test] + fn test_should_skip_quote_block() { + let quote = "> This is a quoted paragraph that spans\n> multiple lines and has enough content."; + assert!(should_skip_chunk(quote)); + } + + #[test] + fn test_mixed_content_not_skipped() { + let mixed = "Some prose paragraph here.\n\n- a list item\n\nMore prose follows."; + assert!(!should_skip_chunk(mixed)); + } + + #[test] + fn test_strip_heading_context() { + let text = "## My Heading\n\nThis is the actual content of the paragraph."; + let stripped = strip_heading_context(text); + assert_eq!(stripped, "This is the actual content of the paragraph."); + } + + #[test] + fn test_strip_heading_context_no_heading() { + let text = "Just some regular paragraph content here."; + let stripped = strip_heading_context(text); + assert_eq!(stripped, "Just some regular paragraph content here."); + } + + #[test] + fn test_compute_line_range() { + let file = "line 1\nline 2\nfoo bar baz\nline 4\nline 5"; + let (start, end) = compute_line_range(file, "foo bar baz"); + assert_eq!(start, 3); + assert_eq!(end, 3); + } + + #[test] + fn test_compute_line_range_multiline() { + let file = "line 1\nline 2\nfoo bar\nbaz qux\nline 5"; + let (start, end) = compute_line_range(file, "foo bar\nbaz qux"); + assert_eq!(start, 3); + assert_eq!(end, 4); + } + + #[test] + fn test_union_find() { + let mut uf = UnionFind::new(5); + uf.union(0, 1); + uf.union(2, 3); + uf.union(1, 3); + assert_eq!(uf.find(0), uf.find(3)); + assert_ne!(uf.find(0), uf.find(4)); + } + + #[test] + fn test_paragraph_hash_deterministic() { + let h1 = paragraph_hash("hello world"); + let h2 = paragraph_hash("hello world"); + assert_eq!(h1, h2); + let h3 = paragraph_hash("hello world!"); + assert_ne!(h1, h3); + } + + #[test] + fn test_excerpt_short() { + let text = "Short text."; + assert_eq!(excerpt(text), "Short text."); + } + + #[test] + fn test_excerpt_long() { + let text = "A".repeat(300); + let ex = excerpt(&text); + assert!(ex.len() < 300); + assert!(ex.ends_with('…')); + } + + #[test] + fn test_compute_candidates_high_similarity() { + let base_embedding = vec![1.0f32; 16]; + let similar_embedding = { + let mut v = vec![1.0f32; 16]; + v[0] = 0.99; + v + }; + let distant_embedding = vec![0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, + 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + + let chunks_owned = vec![ + DocChunkRow { + chunk_id: "a.md#0".to_string(), + rel_path: "a.md".to_string(), + chunk_index: 0, + chunk_text: "some text content that is long enough to not be filtered".to_string(), + embedding: base_embedding, + dim: 16, + model_id: "test".to_string(), + }, + DocChunkRow { + chunk_id: "b.md#0".to_string(), + rel_path: "b.md".to_string(), + chunk_index: 0, + chunk_text: "some text content that is long enough to not be filtered".to_string(), + embedding: similar_embedding, + dim: 16, + model_id: "test".to_string(), + }, + DocChunkRow { + chunk_id: "c.md#0".to_string(), + rel_path: "c.md".to_string(), + chunk_index: 0, + chunk_text: "completely different paragraph content here for testing".to_string(), + embedding: distant_embedding, + dim: 16, + model_id: "test".to_string(), + }, + ]; + + let chunks: Vec<&DocChunkRow> = chunks_owned.iter().collect(); + let candidates = compute_candidates(&chunks); + + let high_sim: Vec<_> = candidates + .iter() + .filter(|c| c.marking_reason == "high_similarity") + .collect(); + assert!( + !high_sim.is_empty(), + "should detect high similarity between a.md and b.md" + ); + } + + #[test] + fn test_compute_candidates_isolated() { + let chunks_owned = vec![ + DocChunkRow { + chunk_id: "a.md#0".to_string(), + rel_path: "a.md".to_string(), + chunk_index: 0, + chunk_text: "this is paragraph content in document a for testing purposes".to_string(), + embedding: vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + dim: 8, + model_id: "test".to_string(), + }, + DocChunkRow { + chunk_id: "b.md#0".to_string(), + rel_path: "b.md".to_string(), + chunk_index: 0, + chunk_text: "this is paragraph content in document b for testing purposes".to_string(), + embedding: vec![0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], + dim: 8, + model_id: "test".to_string(), + }, + DocChunkRow { + chunk_id: "c.md#0".to_string(), + rel_path: "c.md".to_string(), + chunk_index: 0, + chunk_text: "this is paragraph content in document c for testing purposes".to_string(), + embedding: vec![0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], + dim: 8, + model_id: "test".to_string(), + }, + ]; + + let chunks: Vec<&DocChunkRow> = chunks_owned.iter().collect(); + let candidates = compute_candidates(&chunks); + + let isolated: Vec<_> = candidates + .iter() + .filter(|c| c.marking_reason == "semantic_isolated") + .collect(); + assert_eq!( + isolated.len(), + 3, + "all chunks are orthogonal, all should be isolated" + ); + } + + #[test] + fn test_skip_table_heavy() { + let table = "| Name | Age | City |\n|------|-----|------|\n| Alice | 30 | NYC |\n| Bob | 25 | LA |"; + assert!(should_skip_chunk(table), "pure table should be skipped"); + } + + #[test] + fn test_skip_table_mixed_majority() { + let mixed = "Some intro text here.\n| Col A | Col B |\n|-------|-------|\n| val1 | val2 |\n| val3 | val4 |\n| val5 | val6 |"; + assert!(should_skip_chunk(mixed), "table-heavy content (>50% table lines) should be skipped"); + } + + #[test] + fn test_keep_prose_with_pipe() { + // Only 1 out of 5 lines contains `|` (20%), below the 30% threshold + let prose = "This is a paragraph about Unix pipes. We use | to chain commands.\nAnother line of normal prose about topics.\nA third line discussing ideas and concepts in detail.\nFourth line with more context about the subject.\nFifth line wrapping up the discussion on this matter."; + assert!(!should_skip_chunk(prose), "prose mentioning | should not be skipped"); + } + + #[test] + fn test_skip_frontmatter() { + let fm = "---\ntitle: My Note\ndate: 2026-01-01\ntags: [rust, learning]\n---"; + assert!(should_skip_chunk(fm), "YAML frontmatter should be skipped"); + } + + #[test] + fn test_skip_heading_only() { + let headings = "# Chapter 1\n## Section A\n### Subsection"; + assert!(should_skip_chunk(headings), "heading-only content should be skipped"); + } + + #[test] + fn test_keep_heading_with_prose() { + let mixed = "# My Thoughts\nThis is a paragraph with actual prose content that contains meaningful ideas worth challenging."; + assert!(!should_skip_chunk(mixed), "heading + prose should not be skipped"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ddb5d6..dd1efd5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,9 +13,13 @@ use std::time::{Duration, Instant, UNIX_EPOCH}; use tauri::{AppHandle, Emitter}; mod ai_conversations; +mod challenge_feedback; +mod challenge_prompts; mod challenge_review; mod depth_decisions; mod cognitive_report; +mod cognitive_push; +mod growth_story; mod knowforge_analytics; mod llm; mod note_privacy; @@ -32,6 +36,7 @@ mod rebuild_progress; mod semantic_index; mod workspace_text_search; mod understanding_graph; +mod latent_paragraphs; mod link_recommendation; mod topic_network; mod tools; @@ -269,6 +274,42 @@ async fn cleanup_expired_tool_results(workspace_root: &Path) { } } +/// 检查并发送认知回顾推送通知 +async fn check_and_send_cognitive_push(root: &Path, app_handle: &tauri::AppHandle) { + let config = match vault_config::load_cognitive_merged(root) { + Ok(c) => c, + Err(_) => return, + }; + let notifications = cognitive_push::check_and_build_notifications(root, &config); + + if notifications.is_empty() { + return; + } + + use tauri_plugin_notification::NotificationExt; + for n in ¬ifications { + let _ = app_handle + .notification() + .builder() + .title(&n.title) + .body(&n.body) + .show(); + } + + // 更新 last_sent 时间戳 + let now_str = chrono::Local::now().to_rfc3339(); + let patch = vault_config::VaultConfigPatch { + cognitive: Some(vault_config::CognitiveConfigPatch { + cognitive_push_last_sent: Some(Some(now_str)), + ..Default::default() + }), + ..Default::default() + }; + if let Err(e) = vault_config::save_patch(root, patch) { + eprintln!("[cognitive_push] failed to update last_sent: {e}"); + } +} + #[tauri::command] async fn open_workspace( root: String, @@ -375,6 +416,62 @@ async fn open_workspace( cleanup_expired_tool_results(&cleanup_root).await; }); + // Latent paragraph scan: run when embedding index exists but candidates + // are empty or were generated by an outdated filter version. + { + let scan_root = canonical_root.clone(); + let scan_app = app_handle.clone(); + std::thread::spawn(move || { + let conn = match semantic_index::open_embedding_db(&scan_root) { + Ok(c) => c, + Err(_) => return, // no embedding DB yet, skip + }; + let has_chunks: bool = conn + .query_row("SELECT count(*) FROM doc_chunks", [], |r| r.get::<_, i64>(0)) + .unwrap_or(0) + > 0; + if !has_chunks { + return; + } + // Check filter version — clears outdated candidates if needed + let version_changed = latent_paragraphs::invalidate_if_filter_changed(&conn).unwrap_or(false); + let has_candidates: bool = conn + .query_row( + "SELECT count(*) FROM thought_candidates WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL", + [], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) + > 0; + if !has_candidates { + use tauri::Manager; + eprintln!( + "[open_workspace] triggering latent scan (version_changed={version_changed}, no active candidates)" + ); + if let Some(ec) = scan_app.try_state::>() { + if let Err(e) = latent_paragraphs::scan_vault(&conn, &ec, &scan_root) { + eprintln!("[open_workspace] latent scan error: {e}"); + } + } + } + }); + } + + // 认知回顾推送:启动时检查一次,之后每 30 分钟定期检查 + { + let push_root = canonical_root.clone(); + let push_app = app_handle.clone(); + tokio::spawn(async move { + check_and_send_cognitive_push(&push_root, &push_app).await; + let mut interval = + tokio::time::interval(tokio::time::Duration::from_secs(30 * 60)); + loop { + interval.tick().await; + check_and_send_cognitive_push(&push_root, &push_app).await; + } + }); + } + Ok(nodes) } @@ -1530,6 +1627,140 @@ async fn search_thought_for_invite( .map_err(|e| e.to_string())? } +#[tauri::command] +async fn get_thought_growth_story( + thought_id: String, + state: tauri::State<'_, WorkspaceState>, +) -> Result { + let root = lock_workspace_root(&state)?; + let tid = thought_id.trim().to_string(); + if tid.is_empty() { + return Err("thought_id is empty".to_string()); + } + tauri::async_runtime::spawn_blocking(move || growth_story::build_growth_story(&root, &tid)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn export_growth_story_as_image( + thought_id: String, + _markdown: String, + state: tauri::State<'_, WorkspaceState>, + app: tauri::AppHandle, +) -> Result { + #[allow(unused_imports)] + use tauri::Manager; // required for WebviewWindowBuilder::new(&app, ...) + use tauri::Listener; + + let root = lock_workspace_root(&state)?; + let tid = thought_id.trim().to_string(); + if tid.is_empty() { + return Err("thought_id is empty".to_string()); + } + + let tid_clone = tid.clone(); + let story = tauri::async_runtime::spawn_blocking(move || growth_story::build_growth_story(&root, &tid_clone)) + .await + .map_err(|e| e.to_string())??; + + let html = growth_story::to_html_card(&story); + + // Create a hidden webview to render the HTML + let label = format!("kf-growth-story-{}", uuid::Uuid::new_v4()); + let event_name = format!("kf-growth-story-result-{}", label); + + let (tx, rx) = tokio::sync::oneshot::channel::>(); + let tx = std::sync::Mutex::new(Some(tx)); + + let listener_id = app.listen(&event_name, move |event: tauri::Event| { + if let Some(tx) = tx.lock().unwrap().take() { + if let Ok(data) = serde_json::from_str::>(event.payload()) { + let _ = tx.send(data); + } + } + }); + + // Create a data URL from the HTML + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(html.as_bytes()); + let data_url = format!("data:text/html;base64,{}", encoded); + + let url: url::Url = data_url.parse().map_err(|e| format!("invalid data URL: {e}"))?; + + let extract_event_name = event_name.clone(); + let webview = tauri::WebviewWindowBuilder::new(&app, &label, tauri::WebviewUrl::External(url)) + .visible(false) + .focused(false) + .inner_size(680.0, 800.0) + .on_page_load(move |wv, payload| { + if let tauri::webview::PageLoadEvent::Finished = payload.event() { + let ev = extract_event_name.clone(); + let wv = wv.clone(); + tauri::async_runtime::spawn(async move { + // Wait for rendering + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + // Capture the page as PNG + let js = format!( + r#"(async function(){{ + try {{ + // Use canvas to capture + const canvas = await html2canvas(document.body, {{ scale: 2 }}); + const dataUrl = canvas.toDataURL('image/png'); + const base64 = dataUrl.split(',')[1]; + window.__TAURI_INTERNALS__.invoke('plugin:event|emit', {{ + event: '{ev}', + payload: JSON.stringify(Array.from(atob(base64), c => c.charCodeAt(0))) + }}).catch(function() {{}}); + }} catch(e) {{ + // Fallback: send empty array + window.__TAURI_INTERNALS__.invoke('plugin:event|emit', {{ + event: '{ev}', + payload: JSON.stringify([]) + }}).catch(function() {{}}); + }} + }})()"#, + ev = ev + ); + let _ = wv.eval(&js); + }); + } + }) + .build() + .map_err(|e| format!("webview creation failed: {e}"))?; + + let result = tokio::time::timeout(std::time::Duration::from_secs(10), rx).await; + + app.unlisten(listener_id); + let _ = webview.destroy(); + + match result { + Ok(Ok(data)) if !data.is_empty() => { + // Show save dialog + use tauri_plugin_dialog::DialogExt; + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_title("保存成长故事图片") + .set_file_name(&format!("growth-story-{}.png", tid)) + .add_filter("PNG Image", &["png"]) + .save_file(move |file_path| { + let _ = tx.send(file_path); + }); + + let file_path = rx.await + .map_err(|e| format!("dialog channel error: {e}"))? + .ok_or("save dialog cancelled")?; + + let path = file_path.into_path() + .map_err(|e| format!("convert file path failed: {e}"))?; + std::fs::write(&path, &data).map_err(|e| format!("write file failed: {e}"))?; + Ok(path.to_string_lossy().to_string()) + } + _ => Err("capture failed or timed out".to_string()), + } +} + #[tauri::command] async fn search_workspace_text( args: workspace_text_search::SearchWorkspaceTextArgs, @@ -1705,7 +1936,103 @@ async fn add_manual_topic_semantic( .map_err(|e| e.to_string())? } -#[cfg_attr(mobile, tauri::mobile_entry_point)] +#[tauri::command] +async fn list_latent_candidates( + state: tauri::State<'_, WorkspaceState>, + limit: Option, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + let limit = limit.unwrap_or(100).min(500); + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::list_candidates(&conn, limit) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn trigger_latent_scan( + state: tauri::State<'_, WorkspaceState>, + embed_cache: tauri::State<'_, std::sync::Arc>, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + let ec = embed_cache.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::scan_vault(&conn, &ec, &root)?; + latent_paragraphs::list_candidates(&conn, 100) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn promote_candidate_to_thought( + candidate_id: String, + state: tauri::State<'_, WorkspaceState>, +) -> Result { + let root = lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let embed_conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::promote_candidate(&embed_conn, &root, &candidate_id) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn dismiss_latent_candidate( + candidate_id: String, + state: tauri::State<'_, WorkspaceState>, +) -> Result<(), String> { + let root = lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::dismiss_candidate(&conn, &candidate_id) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn check_cognitive_push_now( + state: tauri::State<'_, WorkspaceState>, + app_handle: tauri::AppHandle, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + let config = vault_config::load_cognitive_merged(&root) + .map_err(|e| format!("failed to load config: {e}"))?; + let notifications = cognitive_push::check_and_build_notifications(&root, &config); + + use tauri_plugin_notification::NotificationExt; + for n in ¬ifications { + let _ = app_handle + .notification() + .builder() + .title(&n.title) + .body(&n.body) + .show(); + } + + // 更新 last_sent + if !notifications.is_empty() { + let now_str = chrono::Local::now().to_rfc3339(); + let patch = vault_config::VaultConfigPatch { + cognitive: Some(vault_config::CognitiveConfigPatch { + cognitive_push_last_sent: Some(Some(now_str)), + ..Default::default() + }), + ..Default::default() + }; + if let Err(e) = vault_config::save_patch(&root, patch) { + eprintln!("[cognitive_push] failed to update last_sent: {e}"); + } + } + + Ok(notifications) +} + pub fn run() { tauri::Builder::default() .manage(WorkspaceState::default()) @@ -1726,6 +2053,7 @@ pub fn run() { Arc::new(tools::ToolContextFactory::new(audit_sink, privacy_filter)) }) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ open_workspace, refresh_md_tree, @@ -1773,11 +2101,16 @@ pub fn run() { apply_challenge_pass_to_thought, append_ai_thought_reference, cognitive_report::generate_cognitive_report, + check_cognitive_push_now, + get_thought_growth_story, + export_growth_story_as_image, understanding_graph::scan_understanding_graph, challenge_review::generate_challenge_question, challenge_review::evaluate_challenge_answer, challenge_review::list_review_queue, challenge_review::count_vault_thoughts_for_review, + challenge_feedback::submit_challenge_feedback, + challenge_feedback::get_feedback_stats, search_thought_for_invite, list_depth_decisions, passive_highlight::detect_passive_highlight, @@ -1802,7 +2135,11 @@ pub fn run() { skills::commands::delete_custom_skill, skills::commands::reload_custom_skills, skills::commands::list_available_tools, - onboarding::seed_onboarding_content + onboarding::seed_onboarding_content, + list_latent_candidates, + trigger_latent_scan, + promote_candidate_to_thought, + dismiss_latent_candidate ]) .setup(|app| { use tauri::Manager; diff --git a/src-tauri/src/semantic_index.rs b/src-tauri/src/semantic_index.rs index 6b4ed0f..966bd12 100644 --- a/src-tauri/src/semantic_index.rs +++ b/src-tauri/src/semantic_index.rs @@ -94,6 +94,7 @@ fn init_embedding_schema(conn: &Connection) -> Result<(), String> { "#, ) .map_err(|e| format!("init embedding schema: {e}"))?; + crate::latent_paragraphs::init_candidates_schema(conn)?; Ok(()) } @@ -1226,6 +1227,21 @@ fn rebuild_index_impl(vault_root: &Path, app: &AppHandle, resume: bool) -> Resul }), ); + // Fire-and-forget latent paragraph scan after successful rebuild + if indexed_chunks > 0 { + let scan_root = vault_root.to_path_buf(); + let scan_app = app.clone(); + std::thread::spawn(move || { + if let Ok(conn) = open_embedding_db(&scan_root) { + if let Some(ec) = scan_app.try_state::>() { + if let Err(e) = crate::latent_paragraphs::scan_vault(&conn, &ec, &scan_root) { + eprintln!("[latent_paragraphs] scan_vault error: {e}"); + } + } + } + }); + } + Ok(IndexBuildResult { indexed_chunks, indexed_thoughts, @@ -1538,5 +1554,19 @@ pub fn incremental_reindex_note(vault_root: &Path, app: &AppHandle, rel_path: &s if let Some(ec) = app.try_state::>() { ec.invalidate(); } + let scan_root = vault_root.to_path_buf(); + let scan_rel = rel_path.to_string(); + let scan_app = app.clone(); + std::thread::spawn(move || { + if let Ok(conn) = open_embedding_db(&scan_root) { + if let Some(ec) = scan_app.try_state::>() { + if let Err(e) = crate::latent_paragraphs::incremental_scan_for_note( + &conn, &ec, &scan_root, &scan_rel, + ) { + eprintln!("[latent_paragraphs] incremental scan error: {e}"); + } + } + } + }); } } diff --git a/src-tauri/src/thought_parser.rs b/src-tauri/src/thought_parser.rs index ee3bb43..1e8f94f 100644 --- a/src-tauri/src/thought_parser.rs +++ b/src-tauri/src/thought_parser.rs @@ -104,6 +104,12 @@ pub struct KfThoughtMeta { /// 上次成功回顾时间(ISO8601,用于遗忘曲线) #[serde(default, skip_serializing_if = "Option::is_none")] pub last_reviewed_at: Option, + /// SM-2 easiness factor (default 2.5, floor 1.3) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub srs_easiness_factor: Option, + /// SM-2 current interval in days + #[serde(default, skip_serializing_if = "Option::is_none")] + pub srs_interval_days: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub history: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -443,6 +449,8 @@ pub fn new_thought_meta(id: &str, temporary: bool, source: &str) -> KfThoughtMet temporary, challenge_pass_count: 0, last_reviewed_at: None, + srs_easiness_factor: None, + srs_interval_days: None, history: vec![ThoughtHistoryEntry { date: now, entry_type: "created".to_string(), @@ -1114,20 +1122,111 @@ pub fn append_ai_thought_reference_to_markdown( /// 挑战回顾「通过」时写回:递增 YAML 元数据 + 更新侧车 SQLite;**不改写正文 callout**。 /// -/// `passed == false` 时原文不变(跳过或敷衍时不写 `last_reviewed_at`)。 +/// SM-2 quality rating derived from evaluation outcome. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChallengeQuality { + Passed, // q=4: genuine engagement, correct + Sloppy, // q=3: attempted but halfhearted + Failed, // q=1: did not pass +} + +impl ChallengeQuality { + fn sm2_q(self) -> f64 { + match self { + ChallengeQuality::Passed => 4.0, + ChallengeQuality::Sloppy => 3.0, + ChallengeQuality::Failed => 1.0, + } + } + + fn is_pass(self) -> bool { + matches!(self, ChallengeQuality::Passed) + } +} + +/// SM-2 scheduling state. +#[derive(Debug, Clone)] +pub struct SrsState { + pub easiness_factor: f64, + pub interval_days: f64, + pub repetition_count: u32, +} + +impl SrsState { + pub fn initial() -> Self { + Self { + easiness_factor: 2.5, + interval_days: 0.0, + repetition_count: 0, + } + } + + /// Migrate from legacy fixed-interval data. + pub fn from_legacy(challenge_pass_count: u32, existing_ef: Option, existing_interval: Option) -> Self { + if let (Some(ef), Some(iv)) = (existing_ef, existing_interval) { + return Self { + easiness_factor: ef, + interval_days: iv, + repetition_count: challenge_pass_count, + }; + } + let interval = match challenge_pass_count { + 0 => 0.0, + 1 => 1.0, + 2 => 6.0, + n => { + let mut iv = 6.0; + for _ in 2..n { + iv *= 2.5; + } + iv + } + }; + Self { + easiness_factor: 2.5, + interval_days: interval, + repetition_count: challenge_pass_count, + } + } +} + +/// Compute the next SM-2 state after a review with the given quality. +pub fn sm2_next(state: &SrsState, quality: ChallengeQuality) -> SrsState { + let q = quality.sm2_q(); + let new_ef = (state.easiness_factor + (0.1 - (5.0 - q) * (0.08 + (5.0 - q) * 0.02))) + .max(1.3); + + if q < 3.0 { + SrsState { + easiness_factor: new_ef, + interval_days: 1.0, + repetition_count: 0, + } + } else { + let new_interval = match state.repetition_count { + 0 => 1.0, + 1 => 6.0, + _ => (state.interval_days * new_ef).round().max(1.0), + }; + SrsState { + easiness_factor: new_ef, + interval_days: new_interval, + repetition_count: state.repetition_count + 1, + } + } +} + +/// Update thought metadata after challenge review (any outcome). +/// +/// `quality == Failed` still writes back SRS state (to reset interval); +/// only `Passed` increments `challenge_pass_count` and advances maturity. pub fn apply_challenge_pass_to_markdown_vault( vault_root: &Path, _rel_path: &str, markdown: &str, thought_id: &str, - passed: bool, + quality: ChallengeQuality, ) -> Result { - if !passed { - return Ok(ApplyChallengePassToMarkdownOutcome { - markdown: markdown.to_string(), - maturity_change: None, - }); - } if thought_id.is_empty() { return Err("thought_id is empty".to_string()); } @@ -1159,10 +1258,23 @@ pub fn apply_challenge_pass_to_markdown_vault( let m = &mut meta_vec[idx]; let prev_maturity = m.maturity; - m.challenge_pass_count = m.challenge_pass_count.saturating_add(1); - let pass_count = m.challenge_pass_count; + + let srs_before = SrsState::from_legacy( + m.challenge_pass_count, + m.srs_easiness_factor, + m.srs_interval_days, + ); + let srs_after = sm2_next(&srs_before, quality); + + m.srs_easiness_factor = Some(srs_after.easiness_factor); + m.srs_interval_days = Some(srs_after.interval_days); m.last_reviewed_at = Some(Utc::now().format("%Y-%m-%d").to_string()); - m.maturity = maturity_after_challenge_pass(prev_maturity, pass_count); + + if quality.is_pass() { + m.challenge_pass_count = m.challenge_pass_count.saturating_add(1); + m.maturity = maturity_after_challenge_pass(prev_maturity, m.challenge_pass_count); + } + let maturity_change = if prev_maturity != m.maturity { Some(ThoughtMaturityChangedCore { thought_id: thought_id.to_string(), @@ -1175,9 +1287,15 @@ pub fn apply_challenge_pass_to_markdown_vault( }; let now_rfc = Utc::now().to_rfc3339(); m.updated = now_rfc.clone(); + + let entry_type = if quality.is_pass() { + "challenge-review-pass" + } else { + "challenge-review-attempt" + }; m.history.push(ThoughtHistoryEntry { date: now_rfc, - entry_type: "challenge-review-pass".to_string(), + entry_type: entry_type.to_string(), source: "challenge-review".to_string(), diff_summary: None, }); @@ -1192,6 +1310,8 @@ pub fn apply_challenge_pass_to_markdown_vault( &m.updated, m.challenge_pass_count, m.last_reviewed_at.as_deref(), + m.srs_easiness_factor, + m.srs_interval_days, )?; Ok(ApplyChallengePassToMarkdownOutcome { @@ -1680,12 +1800,20 @@ kf-thoughts: } #[test] - fn apply_challenge_pass_skipped_when_not_passed() { + fn apply_challenge_failed_still_writes_srs_state() { let dir = tempdir().unwrap(); let root = dir.path(); let md = "---\nkfVaultNoteId: nx\nkf-thoughts:\n- id: t1\n maturity: growing\n created: '2026-01-01T00:00:00Z'\n updated: '2026-01-01T00:00:00Z'\n temporary: false\n---\nBody\n"; - let out = apply_challenge_pass_to_markdown_vault(root, "a.md", md, "t1", false).unwrap(); - assert_eq!(out.markdown, md); + let conn = vault_thoughts_db::open_thoughts_db(root).unwrap(); + vault_thoughts_db::upsert_thought_body( + &conn, "t1", "nx", "a.md", "Body", None, + "growing", false, false, + "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z", 0, None, + ).unwrap(); + let out = apply_challenge_pass_to_markdown_vault(root, "a.md", md, "t1", ChallengeQuality::Failed).unwrap(); + assert!(out.markdown.contains("srsEasinessFactor"), "SRS EF should be written: {}", out.markdown); + assert!(out.markdown.contains("srsIntervalDays"), "SRS interval should be written: {}", out.markdown); + assert!(out.markdown.contains("challengePassCount: 0"), "pass count should NOT increment on failure: {}", out.markdown); assert!(out.maturity_change.is_none()); } @@ -1712,16 +1840,87 @@ kf-thoughts: ) .unwrap(); let out = - apply_challenge_pass_to_markdown_vault(root, "note.md", md, "thought-x", true).unwrap(); + apply_challenge_pass_to_markdown_vault(root, "note.md", md, "thought-x", ChallengeQuality::Passed).unwrap(); assert!(out.markdown.contains("mature"), "{}", out.markdown); assert!(out.markdown.contains("challengePassCount: 1"), "{}", out.markdown); assert!(out.markdown.contains("lastReviewedAt"), "{}", out.markdown); + assert!(out.markdown.contains("srsEasinessFactor"), "{}", out.markdown); let ch = out.maturity_change.expect("growing→mature should emit change"); assert_eq!(ch.thought_id, "thought-x"); assert_eq!(ch.from_maturity, "growing"); assert_eq!(ch.to_maturity, "mature"); } + #[test] + fn sm2_easy_answer_increases_interval() { + let state = SrsState::initial(); + // q=4 → EF unchanged (0.1 - 1*0.1 = 0), interval = 1 (first rep) + let s1 = sm2_next(&state, ChallengeQuality::Passed); + assert_eq!(s1.interval_days, 1.0); + assert_eq!(s1.repetition_count, 1); + assert!((s1.easiness_factor - 2.5).abs() < 0.001); + + // second rep → interval = 6 + let s2 = sm2_next(&s1, ChallengeQuality::Passed); + assert_eq!(s2.interval_days, 6.0); + assert_eq!(s2.repetition_count, 2); + + // third rep → interval = round(6 * 2.5) = 15 + let s3 = sm2_next(&s2, ChallengeQuality::Passed); + assert!(s3.interval_days > 6.0, "interval should grow: {}", s3.interval_days); + assert_eq!(s3.repetition_count, 3); + } + + #[test] + fn sm2_hard_answer_resets_interval() { + let state = SrsState { + easiness_factor: 2.5, + interval_days: 15.0, + repetition_count: 3, + }; + let next = sm2_next(&state, ChallengeQuality::Failed); + assert_eq!(next.interval_days, 1.0); + assert_eq!(next.repetition_count, 0); + assert!(next.easiness_factor < 2.5); + } + + #[test] + fn sm2_ef_floor_at_1_3() { + let mut state = SrsState { + easiness_factor: 1.3, + interval_days: 1.0, + repetition_count: 0, + }; + for _ in 0..10 { + state = sm2_next(&state, ChallengeQuality::Failed); + } + assert!(state.easiness_factor >= 1.3, "EF should not drop below 1.3: {}", state.easiness_factor); + } + + #[test] + fn sm2_sloppy_does_not_reset() { + let state = SrsState { + easiness_factor: 2.5, + interval_days: 6.0, + repetition_count: 2, + }; + let next = sm2_next(&state, ChallengeQuality::Sloppy); + assert!(next.interval_days > 1.0, "sloppy (q=3) should NOT reset interval: {}", next.interval_days); + assert_eq!(next.repetition_count, 3); + } + + #[test] + fn srs_state_from_legacy_migration() { + let legacy = SrsState::from_legacy(3, None, None); + assert_eq!(legacy.easiness_factor, 2.5); + assert_eq!(legacy.repetition_count, 3); + assert!(legacy.interval_days > 0.0); + + let existing = SrsState::from_legacy(3, Some(2.2), Some(12.0)); + assert_eq!(existing.easiness_factor, 2.2); + assert_eq!(existing.interval_days, 12.0); + } + #[test] fn remove_thought_aligned_removes_yaml_and_callout() { let md = r#"--- diff --git a/src-tauri/src/thought_retrieval.rs b/src-tauri/src/thought_retrieval.rs index cba3174..fd0d431 100644 --- a/src-tauri/src/thought_retrieval.rs +++ b/src-tauri/src/thought_retrieval.rs @@ -83,6 +83,10 @@ pub struct VaultThoughtEntry { pub challenge_pass_count: u32, pub temporary: bool, pub private_omitted: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub srs_easiness_factor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub srs_interval_days: Option, } fn clip_thought_body_preview(body: &str, max_chars: usize) -> String { @@ -234,27 +238,16 @@ pub fn enumerate_vault_thought_entries_blocking( let mut scanned = 0usize; let mut stopped_early = false; - for ( - rel, - thought_id, - body, - mat_str, - temporary, - created_at, - _updated_at, - cpc, - last_reviewed_at, - ) in rows - { + for row in rows { if started.elapsed().as_millis() as u64 > REVIEW_QUEUE_DEADLINE_MS { stopped_early = true; break; } scanned += 1; - if temporary || thought_id.is_empty() { + if row.temporary || row.thought_id.is_empty() { continue; } - let Ok(joined) = join_under_root(canonical_root, &rel) else { + let Ok(joined) = join_under_root(canonical_root, &row.rel_path) else { continue; }; if !joined.exists() { @@ -262,19 +255,21 @@ pub fn enumerate_vault_thought_entries_blocking( } let is_private = note_privacy::peek_kf_private_from_md_file(&joined); out.push(VaultThoughtEntry { - rel_path: rel, - thought_id, + rel_path: row.rel_path, + thought_id: row.thought_id, excerpt: if is_private { String::new() } else { - clip_thought_body_preview(&body, 240) + clip_thought_body_preview(&row.body, 240) }, - maturity: thought_parser::thought_maturity_from_storage(&mat_str), - created: created_at, - last_reviewed_at, - challenge_pass_count: cpc.max(0) as u32, - temporary, + maturity: thought_parser::thought_maturity_from_storage(&row.maturity), + created: row.created_at, + last_reviewed_at: row.last_reviewed_at, + challenge_pass_count: row.challenge_pass_count.max(0) as u32, + temporary: row.temporary, private_omitted: is_private, + srs_easiness_factor: row.srs_easiness_factor, + srs_interval_days: row.srs_interval_days, }); } diff --git a/src-tauri/src/vault_config.rs b/src-tauri/src/vault_config.rs index 54f7117..b27ceda 100644 --- a/src-tauri/src/vault_config.rs +++ b/src-tauri/src/vault_config.rs @@ -408,6 +408,15 @@ pub struct CognitiveConfig { /// 忽略气泡后的冷却分钟数(默认 15) #[serde(default = "default_writing_coach_cooldown_minutes")] pub writing_coach_cooldown_minutes: u32, + /// 认知回顾推送总开关(默认关) + #[serde(default)] + pub cognitive_push_enabled: bool, + /// 推送频率:"weekly" | "monthly" | "both"(默认 both) + #[serde(default = "default_cognitive_push_frequency")] + pub cognitive_push_frequency: String, + /// 上次推送时间(ISO 8601),用于调度判定 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cognitive_push_last_sent: Option, } fn default_challenge_review_cap_independent() -> u32 { @@ -450,6 +459,10 @@ fn default_writing_coach_cooldown_minutes() -> u32 { 15 } +fn default_cognitive_push_frequency() -> String { + "both".to_string() +} + impl Default for CognitiveConfig { fn default() -> Self { Self { @@ -471,6 +484,9 @@ impl Default for CognitiveConfig { writing_coach_term_min_chars: default_writing_coach_term_min_chars(), writing_coach_bubble_seconds: default_writing_coach_bubble_seconds(), writing_coach_cooldown_minutes: default_writing_coach_cooldown_minutes(), + cognitive_push_enabled: false, + cognitive_push_frequency: default_cognitive_push_frequency(), + cognitive_push_last_sent: None, } } } @@ -553,6 +569,9 @@ struct CognitiveDiskPartial { writing_coach_term_min_chars: Option, writing_coach_bubble_seconds: Option, writing_coach_cooldown_minutes: Option, + cognitive_push_enabled: Option, + cognitive_push_frequency: Option, + cognitive_push_last_sent: Option, } // --- 网络搜索配置 --- @@ -713,6 +732,9 @@ pub struct CognitiveConfigPatch { pub writing_coach_term_min_chars: Option, pub writing_coach_bubble_seconds: Option, pub writing_coach_cooldown_minutes: Option, + pub cognitive_push_enabled: Option, + pub cognitive_push_frequency: Option, + pub cognitive_push_last_sent: Option>, } #[derive(Debug, Deserialize, Default)] @@ -768,6 +790,10 @@ pub struct CognitiveConfigForUi { pub writing_coach_term_min_chars: u32, pub writing_coach_bubble_seconds: u32, pub writing_coach_cooldown_minutes: u32, + pub cognitive_push_enabled: bool, + pub cognitive_push_frequency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cognitive_push_last_sent: Option, } #[derive(Debug, Serialize)] @@ -1153,6 +1179,15 @@ fn merge_cognitive_from_disk_partial( if let Some(v) = partial.writing_coach_cooldown_minutes { cfg.writing_coach_cooldown_minutes = v; } + if let Some(v) = partial.cognitive_push_enabled { + cfg.cognitive_push_enabled = v; + } + if let Some(v) = partial.cognitive_push_frequency { + cfg.cognitive_push_frequency = v; + } + if partial.cognitive_push_last_sent.is_some() { + cfg.cognitive_push_last_sent = partial.cognitive_push_last_sent; + } normalize_cognitive(&mut cfg); cfg } @@ -1191,6 +1226,9 @@ fn to_cognitive_for_ui(cfg: CognitiveConfig) -> CognitiveConfigForUi { writing_coach_term_min_chars: cfg.writing_coach_term_min_chars, writing_coach_bubble_seconds: cfg.writing_coach_bubble_seconds, writing_coach_cooldown_minutes: cfg.writing_coach_cooldown_minutes, + cognitive_push_enabled: cfg.cognitive_push_enabled, + cognitive_push_frequency: cfg.cognitive_push_frequency.clone(), + cognitive_push_last_sent: cfg.cognitive_push_last_sent.clone(), } } @@ -1254,6 +1292,15 @@ fn apply_cognitive_patch(cfg: &mut CognitiveConfig, patch: CognitiveConfigPatch) if let Some(v) = patch.writing_coach_cooldown_minutes { cfg.writing_coach_cooldown_minutes = v; } + if let Some(v) = patch.cognitive_push_enabled { + cfg.cognitive_push_enabled = v; + } + if let Some(v) = patch.cognitive_push_frequency { + cfg.cognitive_push_frequency = v; + } + if let Some(s) = patch.cognitive_push_last_sent { + cfg.cognitive_push_last_sent = s; + } } fn apply_ai_patch(cfg: &mut AiConfig, patch: AiConfigPatch) { diff --git a/src-tauri/src/vault_thoughts_db.rs b/src-tauri/src/vault_thoughts_db.rs index 410c2ee..79501c1 100644 --- a/src-tauri/src/vault_thoughts_db.rs +++ b/src-tauri/src/vault_thoughts_db.rs @@ -7,7 +7,7 @@ use std::fs; use std::path::{Path, PathBuf}; /// 与 `thought_parser::USER_VERSION` 区分:侧车库独立迁移版本 -pub const THOUGHTS_DB_USER_VERSION: i32 = 2; +pub const THOUGHTS_DB_USER_VERSION: i32 = 3; /// 单条想法正文上限(Unicode 标量个数近似为字符数) pub const MAX_THOUGHT_BODY_CHARS: usize = 131_072; @@ -49,6 +49,26 @@ fn migrate_v1_to_v2(conn: &Connection) -> Result<(), String> { Ok(()) } +fn migrate_v2_to_v3(conn: &Connection) -> Result<(), String> { + let mut stmt = conn + .prepare("PRAGMA table_info(thoughts)") + .map_err(|e| format!("PRAGMA table_info 失败: {e}"))?; + let cols: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + if cols.iter().any(|c| c == "srs_easiness_factor") { + return Ok(()); + } + conn.execute_batch( + "ALTER TABLE thoughts ADD COLUMN srs_easiness_factor REAL;\ + ALTER TABLE thoughts ADD COLUMN srs_interval_days REAL;", + ) + .map_err(|e| format!("迁移 thoughts V3 (SRS) 失败: {e}"))?; + Ok(()) +} + fn init_schema(conn: &Connection) -> Result<(), String> { conn.execute_batch( r#" @@ -75,6 +95,8 @@ fn init_schema(conn: &Connection) -> Result<(), String> { .map_err(|e| format!("初始化 thoughts 表失败: {e}"))?; migrate_v1_to_v2(conn)?; + migrate_v2_to_v3(conn)?; + crate::challenge_feedback::init_feedback_table(conn)?; let ver: i32 = conn .query_row("PRAGMA user_version", [], |row| row.get(0)) @@ -205,13 +227,17 @@ pub fn update_thought_after_challenge( updated_at: &str, challenge_pass_count: u32, last_reviewed_at: Option<&str>, + srs_easiness_factor: Option, + srs_interval_days: Option, ) -> Result<(), String> { conn.execute( r#"UPDATE thoughts SET maturity = ?2, updated_at = ?3, challenge_pass_count = ?4, - last_reviewed_at = ?5 + last_reviewed_at = ?5, + srs_easiness_factor = ?6, + srs_interval_days = ?7 WHERE thought_id = ?1"#, params![ thought_id, @@ -219,6 +245,8 @@ pub fn update_thought_after_challenge( updated_at, challenge_pass_count, last_reviewed_at, + srs_easiness_factor, + srs_interval_days, ], ) .map_err(|e| format!("更新 thought 成熟度失败: {e}"))?; @@ -254,40 +282,44 @@ pub fn graph_thought_stats(conn: &Connection) -> Result } /// 回顾排期:侧车行 + 元数据列(YAML 不再扫 callout);不含独立想法 +#[allow(dead_code)] +pub struct ThoughtRowForReview { + pub rel_path: String, + pub thought_id: String, + pub body: String, + pub maturity: String, + pub temporary: bool, + pub created_at: String, + pub updated_at: String, + pub challenge_pass_count: i64, + pub last_reviewed_at: Option, + pub srs_easiness_factor: Option, + pub srs_interval_days: Option, +} + pub fn list_thought_rows_for_review( conn: &Connection, -) -> Result< - Vec<( - String, - String, - String, - String, - bool, - String, - String, - i64, - Option, - )>, - String, -> { +) -> Result, String> { let mut stmt = conn .prepare( - "SELECT note_rel_path, thought_id, body, maturity, temporary, created_at, updated_at, challenge_pass_count, last_reviewed_at FROM thoughts WHERE standalone = 0", + "SELECT note_rel_path, thought_id, body, maturity, temporary, created_at, updated_at, challenge_pass_count, last_reviewed_at, srs_easiness_factor, srs_interval_days FROM thoughts WHERE standalone = 0", ) .map_err(|e| e.to_string())?; let iter = stmt .query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, i64>(4)? != 0, - row.get::<_, String>(5)?, - row.get::<_, String>(6)?, - row.get::<_, i64>(7)?, - row.get::<_, Option>(8)?, - )) + Ok(ThoughtRowForReview { + rel_path: row.get(0)?, + thought_id: row.get(1)?, + body: row.get(2)?, + maturity: row.get(3)?, + temporary: row.get::<_, i64>(4)? != 0, + created_at: row.get(5)?, + updated_at: row.get(6)?, + challenge_pass_count: row.get(7)?, + last_reviewed_at: row.get(8)?, + srs_easiness_factor: row.get(9)?, + srs_interval_days: row.get(10)?, + }) }) .map_err(|e| e.to_string())?; let mut out = Vec::new(); diff --git a/src-tauri/src/writing_coach.rs b/src-tauri/src/writing_coach.rs index dbd6f05..a7fea9f 100644 --- a/src-tauri/src/writing_coach.rs +++ b/src-tauri/src/writing_coach.rs @@ -11,7 +11,6 @@ use crate::llm::LlmChatMessage; use tokio_util::sync::CancellationToken; use crate::lock_workspace_root; use crate::note_privacy; -use crate::challenge_review; use crate::thought_retrieval::{self, SearchThoughtArgs}; use crate::vault_config::{self, AiConfig, DepthMode}; use crate::vault_context_search::{self, SearchWorkspaceContextArgs, SearchWorkspaceLimits}; @@ -353,7 +352,7 @@ fn filter_response( if reasoning_questions.is_empty() { // 模型输出若全被红线过滤,给一条中性提问,避免空白浮层(语言随界面) - let q = if challenge_review::ui_locale_is_zh(prep.ui_locale.as_deref()) { + let q = if crate::challenge_prompts::ui_locale_is_zh(prep.ui_locale.as_deref()) { FALLBACK_REASONING_QUESTION_ZH } else { FALLBACK_REASONING_QUESTION_EN diff --git a/src/App.tsx b/src/App.tsx index c28e3b7..8eb5d0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,7 +29,7 @@ import { KfPrivateLockIcon } from "./components/KfPrivateLockIcon"; import { OutlineBulkToolbar } from "./components/OutlineBulkToolbar"; import { OutlinePanel } from "./components/OutlinePanel"; import type { CrepeMarkdownEditorApi } from "./components/CrepeMarkdownEditor"; -import { CognitiveReportPanel } from "./components/CognitiveReportPanel"; +import { CognitiveReportPanel } from "./components/cognitive-report/CognitiveReportPanel"; import { CommandPalette } from "./components/CommandPalette"; import { EditorThoughtsPanel } from "./components/EditorThoughtsPanel"; import { EditorWritingCoachHost, type EditorWritingCoachHostHandle } from "./components/EditorWritingCoachHost"; @@ -1361,6 +1361,7 @@ function App() { setLeftPanelView("files"); void onOpenCoachMarkdownPath(relPath); }} + isPathKfPrivate={isPathKfPrivate} /> @@ -1773,6 +1774,11 @@ function App() { setOnboardingOpen(false)} + onStartChallenge={() => { + setOnboardingOpen(false); + localStorage.setItem("knowforge:onboardingCompleted", "true"); + requestOpenChallengeReview(); + }} tauriRuntime={tauriRuntime} /> diff --git a/src/components/AiConversationPanel.tsx b/src/components/AiConversationPanel.tsx index d532a95..039a08e 100644 --- a/src/components/AiConversationPanel.tsx +++ b/src/components/AiConversationPanel.tsx @@ -2,6 +2,8 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useTranslation } from "react-i18next"; import { useAiConversationSession } from "../contexts/AiConversationSessionContext"; +import { useAiConfigStatus } from "../hooks/useAiConfigStatus"; +import AiNotConfiguredGuide from "./AiNotConfiguredGuide"; import type { ThoughtFocusContext } from "../types/aiConversation"; import { useAiNoteContext } from "../contexts/AiNoteContext"; import type { ChatMessage } from "../hooks/useWorkspaceAiConversations"; @@ -155,6 +157,7 @@ export function AiConversationPanel() { createConversation, thoughtFocusContext, } = useAiConversationSession(); + const { isConfigured: aiConfigured } = useAiConfigStatus(workspaceReady); /** 与 stream 事件监听同步,避免闭包读到陈旧的「本会话够了」 */ const enoughForThisChatRef = useRef(enoughForThisChat); @@ -1405,9 +1408,16 @@ export function AiConversationPanel() { {...dragProps} > {messages.length === 0 ? ( -

- {t("aiPanel.empty")} -

+ aiConfigured ? ( +

+ {t("aiPanel.empty")} +

+ ) : ( + + ) ) : ( messages.map((m) => ( import("./SkillManagementPanel")); +// Frozen: Skill management panel hidden from UI (code preserved) +// const SkillManagementPanel = lazy(() => import("./SkillManagementPanel")); /** 与 Tauri 可拖拽窗口配合:排除交互区(非桌面端传空对象) */ export type TauriDragRegionExcludeProps = @@ -25,7 +26,7 @@ export type AiLlmSettingsModalProps = { dragExcludeProps: TauriDragRegionExcludeProps; }; -type SettingsSection = "general" | "ai" | "skills"; +type SettingsSection = "general" | "ai"; /** 左侧「通用」分区:滑块调谐图标 */ function IconGeneralSettings() { @@ -98,24 +99,6 @@ function IconAiLlmSection() { ); } -/** 左侧「技能」分区:扳手/工具图标 */ -function IconSkillsSection() { - return ( - - - - ); -} // --- Provider form state --- @@ -157,6 +140,8 @@ type FormState = { independentReviewEnabled: boolean; challengeReviewDailyCapIndependent: string; challengeReviewDailyCapInline: string; + cognitivePushEnabled: boolean; + cognitivePushFrequency: string; semanticEnabled: boolean; semanticAutoIndex: boolean; semanticSearchWeight: string; @@ -265,6 +250,8 @@ function defaultForm(): FormState { independentReviewEnabled: false, challengeReviewDailyCapIndependent: "3", challengeReviewDailyCapInline: "2", + cognitivePushEnabled: false, + cognitivePushFrequency: "both", semanticEnabled: true, semanticAutoIndex: true, semanticSearchWeight: "0.6", @@ -377,6 +364,8 @@ function vaultConfigToForm(cfg: VaultConfigForUi): FormState { independentReviewEnabled: cognitive.independentReviewEnabled === true, challengeReviewDailyCapIndependent: String(cognitive.challengeReviewDailyCapIndependent ?? 3), challengeReviewDailyCapInline: String(cognitive.challengeReviewDailyCapInline ?? 2), + cognitivePushEnabled: cognitive.cognitivePushEnabled === true, + cognitivePushFrequency: cognitive.cognitivePushFrequency ?? "both", semanticEnabled: semantic.enabled !== false, semanticAutoIndex: semantic.autoIndexOnSave !== false, semanticSearchWeight: String(semantic.searchWeight ?? 0.6), @@ -761,7 +750,7 @@ export function AiLlmSettingsModal({ organizationId: p.organizationId.trim() || null, lastUsedModel: p.defaultModel.trim() || null, isRemote: p.isRemote, - ...(p.apiKeyChanged && p.apiKey.trim() ? { apiKey: p.apiKey.trim() } : {}), + ...(p.apiKeyChanged ? { apiKey: p.apiKey.trim() } : {}), })); const patch: VaultConfigSavePatch = { @@ -796,6 +785,8 @@ export function AiLlmSettingsModal({ independentReviewEnabled: form.independentReviewEnabled, challengeReviewDailyCapIndependent: capInd, challengeReviewDailyCapInline: capInline, + cognitivePushEnabled: form.cognitivePushEnabled, + cognitivePushFrequency: form.cognitivePushFrequency, }, semantic: { enabled: form.semanticEnabled, @@ -921,17 +912,7 @@ export function AiLlmSettingsModal({ {t("settings.aiLlm")} - + {/* Frozen: skills nav button hidden */} {/* 外层固定高度由 .app-modal--settings 控制;此处唯一滚动区适配 General/AI */} @@ -1636,6 +1617,36 @@ export function AiLlmSettingsModal({

{t("settings.challengeReviewDailyCapHint")}

+
+ {t("settings.cognitivePushSection")} + + + +

{t("settings.cognitivePushHint")}

+
+ @@ -1654,18 +1665,7 @@ export function AiLlmSettingsModal({ - ) : ( - {t("settings.loading")}

}> - {}} - embedded={true} - workspaceReady={workspaceReady} - tauriRuntime={tauriRuntime} - dragExcludeProps={dragExcludeProps} - /> -
- )} + ) : null} diff --git a/src/components/AiNotConfiguredGuide.css b/src/components/AiNotConfiguredGuide.css new file mode 100644 index 0000000..2ff37a3 --- /dev/null +++ b/src/components/AiNotConfiguredGuide.css @@ -0,0 +1,48 @@ +.ai-guide { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + padding: 32px 24px; + text-align: center; + color: var(--text-muted, #888); +} + +.ai-guide__icon { + opacity: 0.45; + margin-bottom: 4px; +} + +.ai-guide__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--text-normal, #333); +} + +.ai-guide__desc { + margin: 0; + font-size: 13px; + line-height: 1.5; + max-width: 300px; +} + +.ai-guide__btn { + margin-top: 6px; +} + +/* compact variant */ +.ai-guide--compact { + padding: 16px 12px; + gap: 6px; +} + +.ai-guide--compact .ai-guide__icon { + width: 24px; + height: 24px; +} + +.ai-guide--compact .ai-guide__title { + font-size: 13px; +} diff --git a/src/components/AiNotConfiguredGuide.tsx b/src/components/AiNotConfiguredGuide.tsx new file mode 100644 index 0000000..39daafa --- /dev/null +++ b/src/components/AiNotConfiguredGuide.tsx @@ -0,0 +1,52 @@ +import { useTranslation } from "react-i18next"; +import { dispatchOpenAiSettings } from "../utils/vaultConfigBroadcast"; +import "./AiNotConfiguredGuide.css"; + +function IconKey() { + return ( + + + + + + ); +} + +interface Props { + featureName: string; + featureDescription?: string; + compact?: boolean; +} + +export default function AiNotConfiguredGuide({ featureName, featureDescription, compact }: Props) { + const { t } = useTranslation(); + + return ( +
+ +

+ {t("aiGuide.title", { feature: featureName })} +

+ {!compact && featureDescription && ( +

{featureDescription}

+ )} + +
+ ); +} diff --git a/src/components/CandidatePromoteCard.tsx b/src/components/CandidatePromoteCard.tsx new file mode 100644 index 0000000..50d4b23 --- /dev/null +++ b/src/components/CandidatePromoteCard.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; + +type Props = { + candidateId: string; + onDone: () => void; +}; + +export function CandidatePromoteCard({ candidateId, onDone }: Props) { + const { t } = useTranslation(); + const [busy, setBusy] = useState(false); + const [outcome, setOutcome] = useState<"idle" | "promoted" | "dismissed">("idle"); + + const promote = async () => { + setBusy(true); + try { + await invoke("promote_candidate_to_thought", { candidateId }); + setOutcome("promoted"); + } finally { + setBusy(false); + } + }; + + const dismiss = async () => { + setBusy(true); + try { + await invoke("dismiss_latent_candidate", { candidateId }); + setOutcome("dismissed"); + } finally { + setBusy(false); + } + }; + + if (outcome === "promoted") { + return ( +
+

{t("challengeReview.promoteSuccess")}

+ +
+ ); + } + + if (outcome === "dismissed") { + return ( +
+

{t("challengeReview.promoteDismissed")}

+ +
+ ); + } + + return ( +
+

{t("challengeReview.promotePrompt")}

+
+ + + +
+
+ ); +} diff --git a/src/components/ChallengeFeedbackBar.tsx b/src/components/ChallengeFeedbackBar.tsx new file mode 100644 index 0000000..d0027ea --- /dev/null +++ b/src/components/ChallengeFeedbackBar.tsx @@ -0,0 +1,95 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +type Props = { + thoughtId?: string; + questionText: string; + questionTemplate?: string; +}; + +type Phase = "idle" | "reason" | "done"; + +const REASONS = ["too_easy", "irrelevant", "too_vague", "duplicate"] as const; + +export function ChallengeFeedbackBar({ thoughtId, questionText, questionTemplate }: Props) { + const { t } = useTranslation(); + const [phase, setPhase] = useState("idle"); + const [submitting, setSubmitting] = useState(false); + + const submit = async (rating: "helpful" | "not_helpful", reason?: string) => { + setSubmitting(true); + try { + await invoke("submit_challenge_feedback", { + thoughtId: thoughtId ?? null, + questionText, + questionTemplate: questionTemplate ?? null, + rating, + ratingReason: reason ?? null, + }); + } catch { + // best-effort + } + setSubmitting(false); + setPhase("done"); + }; + + if (phase === "done") { + return ( +
+ {t("challengeReview.feedbackThanks")} +
+ ); + } + + return ( +
+ {phase === "idle" ? ( +
+ {t("challengeReview.feedbackPrompt")} + + +
+ ) : ( +
+ {t("challengeReview.feedbackReasonHint")} +
+ {REASONS.map((r) => ( + + ))} + +
+
+ )} +
+ ); +} diff --git a/src/components/ChallengeReviewInline.tsx b/src/components/ChallengeReviewInline.tsx index 9e2ee8a..93f0d69 100644 --- a/src/components/ChallengeReviewInline.tsx +++ b/src/components/ChallengeReviewInline.tsx @@ -12,6 +12,7 @@ import type { } from "../types/cognitiveTypes"; import { trackKnowforgeEvent } from "../utils/knowforgeAnalytics"; import { AiAssistantMarkdown } from "./AiAssistantMarkdown"; +import { ChallengeFeedbackBar } from "./ChallengeFeedbackBar"; import "./ChallengeReviewInline.css"; type Props = { @@ -60,14 +61,15 @@ export function ChallengeReviewInline({ sloppy: ev.sloppy, thoughtId: thought.thoughtId, }); + await invoke("apply_challenge_pass_to_thought", { + args: { + relPath: thought.relPath, + thoughtId: thought.thoughtId, + passed: ev.passed && !ev.sloppy, + sloppy: ev.sloppy, + }, + }); if (ev.passed && !ev.sloppy) { - await invoke("apply_challenge_pass_to_thought", { - args: { - relPath: thought.relPath, - thoughtId: thought.thoughtId, - passed: true, - }, - }); void trackKnowforgeEvent("review.inline_pass_applied", { thoughtId: thought.thoughtId }); } } catch { @@ -134,6 +136,11 @@ export function ChallengeReviewInline({ className="challenge-review-inline__commentary" content={result?.commentaryMd ?? ""} /> + diff --git a/src/components/ChallengeReviewPanel.css b/src/components/ChallengeReviewPanel.css index 730c75e..a6a0333 100644 --- a/src/components/ChallengeReviewPanel.css +++ b/src/components/ChallengeReviewPanel.css @@ -276,3 +276,157 @@ justify-content: flex-start; flex-wrap: wrap; } + +/* --- ChallengeFeedbackBar --- */ + +.challenge-feedback-bar { + margin: 10px 0 6px; + padding: 8px 10px; + border-radius: 6px; + background: color-mix(in srgb, var(--kf-text-muted, #64748b) 8%, transparent); + font-size: 0.82rem; +} + +.challenge-feedback-bar--done { + background: color-mix(in srgb, #22c55e 10%, transparent); +} + +.challenge-feedback-bar__row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.challenge-feedback-bar__prompt { + color: var(--kf-text-muted, #64748b); + margin-right: 4px; +} + +.challenge-feedback-bar__thanks { + color: #22c55e; +} + +.challenge-feedback-bar__btn { + border: 1px solid color-mix(in srgb, var(--kf-border, #444) 60%, transparent); + background: transparent; + color: inherit; + font: inherit; + font-size: 0.8rem; + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; +} + +.challenge-feedback-bar__btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--kf-text, #fff) 8%, transparent); +} + +.challenge-feedback-bar__btn--helpful:hover:not(:disabled) { + border-color: #22c55e; + color: #22c55e; +} + +.challenge-feedback-bar__btn--not-helpful:hover:not(:disabled) { + border-color: #ef4444; + color: #ef4444; +} + +.challenge-feedback-bar__reasons { + display: flex; + flex-direction: column; + gap: 6px; +} + +.challenge-feedback-bar__tags { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.challenge-feedback-bar__tag { + border: 1px solid color-mix(in srgb, var(--kf-border, #444) 50%, transparent); + background: transparent; + color: inherit; + font: inherit; + font-size: 0.78rem; + padding: 2px 8px; + border-radius: 12px; + cursor: pointer; +} + +.challenge-feedback-bar__tag:hover:not(:disabled) { + background: color-mix(in srgb, #ef4444 12%, transparent); + border-color: #ef4444; +} + +.challenge-feedback-bar__tag--skip { + opacity: 0.6; +} + +.challenge-feedback-bar__tag--skip:hover:not(:disabled) { + opacity: 1; + background: color-mix(in srgb, var(--kf-text, #fff) 8%, transparent); + border-color: var(--kf-border, #444); +} + +/* --- CandidatePromoteCard --- */ + +.candidate-promote-card { + margin-top: 12px; + padding: 12px; + border-radius: 8px; + background: color-mix(in srgb, #8b5cf6, transparent 92%); + border: 1px solid color-mix(in srgb, #8b5cf6, transparent 78%); +} + +.candidate-promote-card--done { + background: color-mix(in srgb, #10b981, transparent 92%); + border-color: color-mix(in srgb, #10b981, transparent 78%); +} + +.candidate-promote-card__prompt { + margin: 0 0 8px; + font-size: 0.85rem; + font-weight: 500; +} + +.candidate-promote-card__msg { + margin: 0 0 6px; + font-size: 0.85rem; +} + +.candidate-promote-card__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.challenge-review-panel__queue-row .challenge-review-panel__candidate-tag { + font-size: 0.68rem; + padding: 1px 5px; + border-radius: 4px; + background: color-mix(in srgb, #8b5cf6, transparent 85%); + color: #7c3aed; + white-space: nowrap; +} + +.challenge-review-panel__related-docs { + font-size: 0.78rem; + color: var(--text-muted, #888); + margin: 2px 0 4px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.challenge-review-panel__related-doc-tag { + font-size: 0.72rem; + padding: 1px 6px; + border-radius: 3px; + background: color-mix(in srgb, #3b82f6, transparent 88%); + color: #2563eb; + white-space: nowrap; +} diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx index d7f3612..679b87f 100644 --- a/src/components/ChallengeReviewPanel.tsx +++ b/src/components/ChallengeReviewPanel.tsx @@ -2,7 +2,7 @@ * 通道一:独立挑战回顾面板(队列 + 单条问答 + 写回)。 */ import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useAiNoteContext } from "../contexts/AiNoteContext"; import { getAppLocale } from "../i18n"; @@ -17,7 +17,11 @@ import type { VaultConfigForUi } from "../types/vaultAiConfig"; import { localTodayKey, useCognitiveFrequencyControl } from "../hooks/useCognitiveFrequencyControl"; import { trackKnowforgeEvent } from "../utils/knowforgeAnalytics"; import { dispatchOpenAiSettings, VAULT_CONFIG_UPDATED_EVENT } from "../utils/vaultConfigBroadcast"; +import { useAiConfigStatus } from "../hooks/useAiConfigStatus"; +import AiNotConfiguredGuide from "./AiNotConfiguredGuide"; import { AiAssistantMarkdown } from "./AiAssistantMarkdown"; +import { CandidatePromoteCard } from "./CandidatePromoteCard"; +import { ChallengeFeedbackBar } from "./ChallengeFeedbackBar"; import "./ChallengeReviewPanel.css"; type Props = { @@ -25,9 +29,21 @@ type Props = { depthMode: DepthMode; }; +/** Strip .md extension and extract display name from rel path */ +function displayName(relPath: string): string { + const name = relPath.split("/").pop() ?? relPath; + return name.replace(/\.md$/i, ""); +} + +/** Stable cache key for a review queue item */ +function itemCacheKey(item: ReviewQueueItem): string { + return item.candidateId || item.thoughtId || `${item.relPath}:${item.startLine ?? 0}`; +} + export function ChallengeReviewPanel({ onClose, depthMode }: Props) { const { t, i18n } = useTranslation(); const { openMarkdownTab } = useAiNoteContext(); + const { isConfigured: aiConfigured } = useAiConfigStatus(true); const freqCtrl = useCognitiveFrequencyControl(); const [queue, setQueue] = useState(null); const [independent, setIndependent] = useState(false); @@ -37,9 +53,14 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { const [phase, setPhase] = useState<"pick" | "qa" | "result">("pick"); const [busy, setBusy] = useState(false); const [evalRes, setEvalRes] = useState(null); + const [templateKind, setTemplateKind] = useState(); /** 当日独立回顾成功次数已达 cap */ const [independentCapBlocked, setIndependentCapBlocked] = useState(false); + // --- Pre-generation pipeline --- + const questionCacheRef = useRef>(new Map()); + const inflightRef = useRef>>(new Map()); + const todayDayStats = useMemo(() => { const k = localTodayKey(); return ( @@ -63,17 +84,69 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { } }, []); + /** Build invoke args for generate_challenge_question */ + const buildQuestionArgs = useCallback( + (item: ReviewQueueItem) => { + const isCandidate = item.sourceType === "candidate"; + return { + thoughtExcerpt: item.excerpt || item.created, + relPath: item.relPath, + depthMode, + uiLocale: getAppLocale(), + ...(!isCandidate && item.thoughtId ? { thoughtId: item.thoughtId } : {}), + ...(isCandidate && item.markingReason ? { markingReason: item.markingReason } : {}), + ...(isCandidate && item.pairedExcerpt ? { pairedExcerpt: item.pairedExcerpt } : {}), + }; + }, + [depthMode], + ); + + /** Fire-and-forget: prefetch question for an item, then chain to the next */ + const prefetchQuestion = useCallback( + (items: ReviewQueueItem[], startIdx: number) => { + const item = items[startIdx]; + if (!item) return; + const key = itemCacheKey(item); + if (questionCacheRef.current.has(key) || inflightRef.current.has(key)) return; + const promise = invoke("generate_challenge_question", { + args: buildQuestionArgs(item), + }); + inflightRef.current.set(key, promise); + promise + .then((g) => { + questionCacheRef.current.set(key, g); + // Chain: prefetch next item (max lookahead = 2) + if (startIdx + 1 < items.length && questionCacheRef.current.size < 3) { + prefetchQuestion(items, startIdx + 1); + } + }) + .catch(() => { + // Ignore — will fall back to on-demand generation + }) + .finally(() => { + inflightRef.current.delete(key); + }); + }, + [buildQuestionArgs], + ); + const hydrateFromVault = useCallback(async () => { try { await freqCtrl.reload(); - await reloadQueue(); + const q = await reloadQueue(); const cfg = await invoke("get_vault_config_for_ui"); setIndependent(cfg.cognitive.independentReviewEnabled === true); setIndependentCapBlocked(!freqCtrl.canStartMoreIndependentReviewsToday()); + // Start pre-generating question for the first item + questionCacheRef.current.clear(); + inflightRef.current.clear(); + if (q && q.items.length > 0) { + prefetchQuestion(q.items, 0); + } } catch { setQueue(null); } - }, [freqCtrl.reload, freqCtrl.canStartMoreIndependentReviewsToday, reloadQueue]); + }, [freqCtrl.reload, freqCtrl.canStartMoreIndependentReviewsToday, reloadQueue, prefetchQuestion]); useEffect(() => { void hydrateFromVault(); @@ -89,27 +162,60 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { const currentItem: ReviewQueueItem | undefined = queue?.items[cursor]; + /** Apply a generated question response to UI state */ + const applyQuestion = (g: GenerateChallengeQuestionResponse) => { + setTemplateKind(g.templateKind || undefined); + if (g.shouldSkip || !g.question.trim()) { + setQuestion(t("challengeReview.fallbackQuestion")); + } else { + setQuestion(g.question); + } + setPhase("qa"); + setAnswer(""); + setEvalRes(null); + }; + /** 仅用于本面板 onClick,不传入 memo 子组件;用 useCallback 也无法在 answer 变化时稳定引用,故保持为普通函数 */ const startRound = async () => { if (!currentItem) return; + const key = itemCacheKey(currentItem); + + // 1. Try cache — instant response + const cached = questionCacheRef.current.get(key); + if (cached) { + questionCacheRef.current.delete(key); + applyQuestion(cached); + if (queue?.items) prefetchQuestion(queue.items, cursor + 1); + return; + } + + // 2. Prefetch in-flight — await the same Promise (no duplicate request) + const inflight = inflightRef.current.get(key); + if (inflight) { + setBusy(true); + try { + const g = await inflight; + applyQuestion(g); + if (queue?.items) prefetchQuestion(queue.items, cursor + 1); + } catch { + setQuestion(t("challengeReview.fallbackQuestion")); + setPhase("qa"); + setAnswer(""); + setEvalRes(null); + } finally { + setBusy(false); + } + return; + } + + // 3. No cache, no inflight — generate on demand setBusy(true); try { const g = await invoke("generate_challenge_question", { - args: { - thoughtExcerpt: currentItem.excerpt || currentItem.created, - relPath: currentItem.relPath, - depthMode, - uiLocale: getAppLocale(), - }, + args: buildQuestionArgs(currentItem), }); - if (g.shouldSkip || !g.question.trim()) { - setQuestion(t("challengeReview.fallbackQuestion")); - } else { - setQuestion(g.question); - } - setPhase("qa"); - setAnswer(""); - setEvalRes(null); + applyQuestion(g); + if (queue?.items) prefetchQuestion(queue.items, cursor + 1); } finally { setBusy(false); } @@ -130,23 +236,28 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { }); setEvalRes(ev); setPhase("result"); + const isCandidate = currentItem.sourceType === "candidate"; void trackKnowforgeEvent("review.panel_evaluated", { thoughtId: currentItem.thoughtId, passed: ev.passed, sloppy: ev.sloppy, + sourceType: currentItem.sourceType, }); - if (ev.passed && !ev.sloppy) { + if (!isCandidate) { await invoke("apply_challenge_pass_to_thought", { args: { relPath: currentItem.relPath, thoughtId: currentItem.thoughtId, - passed: true, + passed: ev.passed && !ev.sloppy, + sloppy: ev.sloppy, }, }); - await freqCtrl.recordChallengeIndependentShown(currentItem.thoughtId); - await freqCtrl.reload(); - if (!freqCtrl.canStartMoreIndependentReviewsToday()) { - setIndependentCapBlocked(true); + if (ev.passed && !ev.sloppy) { + await freqCtrl.recordChallengeIndependentShown(currentItem.thoughtId); + await freqCtrl.reload(); + if (!freqCtrl.canStartMoreIndependentReviewsToday()) { + setIndependentCapBlocked(true); + } } } } catch { @@ -178,12 +289,10 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { setCursor(0); return; } - setCursor(() => { - if (passedLast) { - return 0; - } - return Math.min(prevCursor + 1, q.items.length - 1); - }); + const nextCursor = passedLast ? 0 : Math.min(prevCursor + 1, q.items.length - 1); + setCursor(nextCursor); + // Prefetch for the upcoming item (it may already be cached from earlier) + prefetchQuestion(q.items, nextCursor); }; const createdDisplay = useCallback( @@ -226,7 +335,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { ); - if (!independent) { + if (!independent || !aiConfigured) { return (
@@ -235,12 +344,11 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { {t("challengeReview.close")}
-

{t("challengeReview.panelNeedsLlm")}

-
- -
+
); } @@ -329,7 +437,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
{t("challengeReview.panelBatchListTitle", { count: items.length })}
    {items.map((it, i) => ( -
  • +
  • + {currentItem.sourceType === "candidate" && currentItem.candidateId ? ( + + ) : items.length > 1 ? ( + + ) : null} {openMarkdownTab ? ( + ) : null} @@ -430,7 +624,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { {phase === "result" && evalRes ? (
    {evalRes.sloppy ?

    {t("challengeReview.sloppyHint")}

    : null} - {evalRes.passed ? ( + {evalRes.passed && currentItem?.sourceType !== "candidate" ? ( <>

    {t("challengeReview.passed")}

    {!evalRes.sloppy ? ( @@ -439,14 +633,28 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { ) : null} -
    - - -
    + {currentItem?.sourceType === "candidate" && currentItem.candidateId ? ( + void goNext().catch(() => {})} + /> + ) : ( + <> + +
    + + +
    + + )}
    ) : null} diff --git a/src/components/CognitiveReportPanel.css b/src/components/CognitiveReportPanel.css deleted file mode 100644 index 432b543..0000000 --- a/src/components/CognitiveReportPanel.css +++ /dev/null @@ -1,117 +0,0 @@ -.cognitive-report-backdrop { - position: fixed; - inset: 0; - z-index: 11100; - background: rgba(0, 0, 0, 0.4); - display: flex; - align-items: center; - justify-content: center; - padding: 24px 16px; -} - -.cognitive-report { - width: min(640px, 100%); - max-height: min(86vh, 720px); - overflow: hidden; - display: flex; - flex-direction: column; - border-radius: 12px; - border: 1px solid color-mix(in srgb, var(--kf-border, #444) 80%, transparent); - background: var(--kf-panel-bg, #1a1a1a); - color: var(--kf-text, #eee); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45); -} - -.cognitive-report__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px; - border-bottom: 1px solid color-mix(in srgb, var(--kf-border, #444) 60%, transparent); -} - -.cognitive-report__title { - margin: 0; - font-size: 16px; - font-weight: 600; -} - -.cognitive-report__close { - border: none; - background: transparent; - color: inherit; - font-size: 20px; - line-height: 1; - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; -} - -.cognitive-report__close:hover { - background: color-mix(in srgb, var(--kf-text, #fff) 8%, transparent); -} - -.cognitive-report__body { - padding: 12px 14px 16px; - overflow-y: auto; - font-size: 14px; - line-height: 1.45; -} - -.cognitive-report__muted { - opacity: 0.7; - font-size: 13px; -} - -.cognitive-report__grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 10px; - margin: 12px 0; -} - -.cognitive-report__stat { - padding: 10px; - border-radius: 8px; - background: color-mix(in srgb, var(--kf-text, #fff) 5%, transparent); -} - -.cognitive-report__stat dt { - margin: 0; - font-size: 12px; - opacity: 0.75; -} - -.cognitive-report__stat dd { - margin: 4px 0 0; - font-size: 18px; - font-weight: 600; -} - -.cognitive-report__section-title { - margin: 18px 0 8px; - font-size: 14px; - font-weight: 600; -} - -.cognitive-report__timeline { - margin: 0; - padding-left: 18px; -} - -.cognitive-report__footer { - padding: 10px 14px; - border-top: 1px solid color-mix(in srgb, var(--kf-border, #444) 60%, transparent); - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - font-size: 12px; -} - -.cognitive-report__footer label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; -} diff --git a/src/components/CognitiveReportPanel.tsx b/src/components/CognitiveReportPanel.tsx deleted file mode 100644 index 6b90fe2..0000000 --- a/src/components/CognitiveReportPanel.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * 认知成长报告:展示 `generate_cognitive_report` 返回的统计事实(无评判语气)。 - */ - -import { invoke, isTauri } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { CognitiveReportForUi } from "../types/motivationFeedback"; -import "./CognitiveReportPanel.css"; - -const DISABLE_KEY = "knowforge:disableCognitiveReport"; - -type Props = { - open: boolean; - onClose: () => void; -}; - -export function CognitiveReportPanel({ open, onClose }: Props) { - const { t } = useTranslation(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [data, setData] = useState(null); - const [disabled, setDisabled] = useState(() => { - try { - return localStorage.getItem(DISABLE_KEY) === "1"; - } catch { - return false; - } - }); - - const load = useCallback(async () => { - if (!isTauri()) { - setError("Not available."); - return; - } - setLoading(true); - setError(null); - try { - const r = await invoke("generate_cognitive_report"); - setData(r); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - if (open && !disabled) { - void load(); - } - }, [open, disabled, load]); - - useEffect(() => { - if (!open) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") { - e.preventDefault(); - onClose(); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [open, onClose]); - - const toggleDisabled = useCallback((next: boolean) => { - setDisabled(next); - try { - if (next) localStorage.setItem(DISABLE_KEY, "1"); - else localStorage.removeItem(DISABLE_KEY); - } catch { - /* ignore */ - } - }, []); - - if (!open) { - return null; - } - - return ( -
    e.target === e.currentTarget && onClose()}> -
    -
    -

    - {t("cognitiveReport.title")} -

    - -
    -
    - {disabled ? ( -

    {t("cognitiveReport.disabledHint")}

    - ) : loading ? ( -

    {t("cognitiveReport.loading")}

    - ) : error ? ( -

    {error}

    - ) : data ? ( - <> -

    - {t("cognitiveReport.scanMeta", { files: data.scannedFiles, thoughts: data.totalThoughts })} -

    -
    -
    -
    {t("cognitiveReport.newThisMonth")}
    -
    {data.newThisMonth}
    -
    -
    -
    {t("cognitiveReport.updatedThisMonth")}
    -
    {data.updatedThisMonth}
    -
    -
    -
    {t("cognitiveReport.totalThoughts")}
    -
    {data.totalThoughts}
    -
    -
    -
    {t("cognitiveReport.aiRefs")}
    -
    {data.totalAiReferences}
    -
    -
    -

    {t("cognitiveReport.maturityDist")}

    -

    - {t("cognitiveReport.maturityLine", { - s: data.maturity.seedling, - g: data.maturity.growing, - m: data.maturity.mature, - })} -

    - {data.prevMonthMaturity ? ( -

    - {t("cognitiveReport.prevMonthLine", { - s: data.prevMonthMaturity.seedling, - g: data.prevMonthMaturity.growing, - m: data.prevMonthMaturity.mature, - })} -

    - ) : ( -

    {t("cognitiveReport.noPrevMonth")}

    - )} -

    {t("cognitiveReport.timelines")}

    - {data.timelines.length === 0 ? ( -

    {t("cognitiveReport.noTimelines")}

    - ) : ( - data.timelines.map((row) => ( -
    -

    - {row.relPath}{row.thoughtId} -

    -

    - {row.excerpt} -

    -
      - {row.history.map((h, i) => ( -
    • - {h.date} · {h.type} · {h.source} - {h.diffSummary ? ` — ${h.diffSummary}` : ""} -
    • - ))} -
    -
    - )) - )} - - ) : null} -
    -
    - - {!disabled ? ( - - ) : null} -
    -
    -
    - ); -} diff --git a/src/components/EditorWritingCoachHost.tsx b/src/components/EditorWritingCoachHost.tsx index bbf21a7..68c904c 100644 --- a/src/components/EditorWritingCoachHost.tsx +++ b/src/components/EditorWritingCoachHost.tsx @@ -10,6 +10,8 @@ import { nearestTextblockText, useWritingCoachTrigger } from "../hooks/useWritin import type { CrepeMarkdownEditorApi } from "./CrepeMarkdownEditor"; import { useTranslation } from "react-i18next"; import { WritingCoachBubble } from "./WritingCoachBubble"; +import { useAiConfigStatus } from "../hooks/useAiConfigStatus"; +import AiNotConfiguredGuide from "./AiNotConfiguredGuide"; import { endPerfTrace, startPerfTrace } from "../utils/perfTrace"; import "./EditorWritingCoachHost.css"; @@ -81,6 +83,7 @@ export const EditorWritingCoachHost = forwardRef(null); const [depthMode, setDepthMode] = useState("auto"); const [writingCoachEnabled, setWritingCoachEnabled] = useState(true); @@ -129,6 +132,7 @@ export const EditorWritingCoachHost = forwardRef { + if (!aiConfigured) { + setShowAiGuide(true); + return; + } const view = editorApiRef.current?.getEditorView(); if (!view || !activePath) return; const text = nearestTextblockText(view.state); @@ -323,7 +334,7 @@ export const EditorWritingCoachHost = forwardRef ({ triggerManually: handleManualTrigger, @@ -344,10 +355,23 @@ export const EditorWritingCoachHost = forwardRef +
    + {showAiGuide ? ( +
    + +
    + +
    +
    + ) : null} {showTriggerBtn ? ( +
    + + ); + } + + const previews = candidates.slice(0, PREVIEW_COUNT); + const uniqueDocs = new Set(candidates.map((c) => c.relPath)).size; + + return ( +
    + {candidates.length} +

    + {t("onboarding.discovery.foundTitle", { count: candidates.length })} +

    +

    + {t("onboarding.discovery.foundSubtitle", { docCount: uniqueDocs })} +

    + +
    + {previews.map((c) => ( +
    + {c.excerpt} + {c.relPath} +
    + ))} +
    + +
    + + +
    +
    + ); +} diff --git a/src/components/OnboardingOverlay.tsx b/src/components/OnboardingOverlay.tsx index 2e32548..2386c88 100644 --- a/src/components/OnboardingOverlay.tsx +++ b/src/components/OnboardingOverlay.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { invoke } from "@tauri-apps/api/core"; import sampleData from "../../resources/onboarding/sample_challenges.json"; +import OnboardingDiscoveryCard from "./OnboardingDiscoveryCard"; import "./OnboardingOverlay.css"; type Step = 1 | 2 | 3 | 4; @@ -9,12 +10,13 @@ type Step = 1 | 2 | 3 | 4; type Props = { open: boolean; onClose: () => void; + onStartChallenge: () => void; tauriRuntime: boolean; }; const TOTAL_STEPS = 4; -export function OnboardingOverlay({ open, onClose, tauriRuntime }: Props) { +export function OnboardingOverlay({ open, onClose, onStartChallenge, tauriRuntime }: Props) { const { t, i18n } = useTranslation(); const isZh = i18n.language.startsWith("zh"); const [step, setStep] = useState(1); @@ -293,39 +295,11 @@ export function OnboardingOverlay({ open, onClose, tauriRuntime }: Props) {

    {t("onboarding.step4Title")}

    {t("onboarding.step4Desc")}

    -
    -
    -
    📌
    -
    - {t("onboarding.step4Tip1Title")} - {t("onboarding.step4Tip1Desc")} -
    -
    -
    -
    📖
    -
    - {t("onboarding.step4Tip2Title")} - {t("onboarding.step4Tip2Desc")} -
    -
    -
    -
    ⌨️
    -
    - {t("onboarding.step4Tip3Title")} - {t("onboarding.step4Tip3Desc")} -
    -
    -
    - -
    - -
    + )} diff --git a/src/components/SkillManagementPanel.tsx b/src/components/SkillManagementPanel.tsx index c4d8a5b..706ce55 100644 --- a/src/components/SkillManagementPanel.tsx +++ b/src/components/SkillManagementPanel.tsx @@ -16,6 +16,8 @@ import { reloadCustomSkills, updateCustomSkill, } from "../utils/skillInvoke"; +import { useAiConfigStatus } from "../hooks/useAiConfigStatus"; +import AiNotConfiguredGuide from "./AiNotConfiguredGuide"; import SkillEditorModal from "./SkillEditorModal"; import "./SkillManagementPanel.css"; @@ -161,6 +163,7 @@ export function SkillManagementPanel(props: SkillManagementPanelProps) { embedded = false, } = props; const { t } = useTranslation(); + const { isConfigured: aiConfigured } = useAiConfigStatus(workspaceReady); const disposedRef = useRef(false); useEffect(() => { @@ -601,6 +604,13 @@ export function SkillManagementPanel(props: SkillManagementPanelProps) { {mode === "idle" ? (
    + {!aiConfigured && ( + + )}

    {t("skillMgmt.placeholderTitle")}

    diff --git a/src/components/ThoughtGrowthStoryCard.css b/src/components/ThoughtGrowthStoryCard.css new file mode 100644 index 0000000..b271c35 --- /dev/null +++ b/src/components/ThoughtGrowthStoryCard.css @@ -0,0 +1,136 @@ +.growth-story-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; +} + +.growth-story-card { + background: var(--panel-bg, #fff); + border: 1px solid var(--border-color, #ddd); + border-radius: 10px; + width: 420px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18); +} + +.growth-story-card__header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid var(--border-color, #eee); +} + +.growth-story-card__title { + font-weight: 600; + font-size: 15px; +} + +.growth-story-card__close { + background: none; + border: none; + font-size: 16px; + cursor: pointer; + color: var(--text-secondary, #888); + padding: 2px 6px; + border-radius: 4px; +} + +.growth-story-card__close:hover { + background: var(--hover-bg, #f0f0f0); +} + +.growth-story-card__loading, +.growth-story-card__error { + padding: 24px; + text-align: center; + color: var(--text-secondary, #888); + font-size: 13px; +} + +.growth-story-card__error { + color: var(--danger-color, #d32f2f); +} + +.growth-story-card__body { + padding: 16px 18px; + overflow-y: auto; +} + +.growth-story-card__summary { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; +} + +.growth-story-card__maturity { + font-size: 14px; + font-weight: 600; +} + +.growth-story-card__stats { + font-size: 12px; + color: var(--text-secondary, #888); +} + +.growth-story__timeline { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.growth-story__timeline-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.growth-story__timeline-icon { + font-size: 14px; + width: 20px; + text-align: center; + flex-shrink: 0; +} + +.growth-story__timeline-date { + color: var(--text-secondary, #888); + font-size: 12px; + width: 40px; + flex-shrink: 0; +} + +.growth-story__timeline-desc { + flex: 1; +} + +.growth-story-card__footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.growth-story-card__btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + border: 1px solid var(--border-color, #ccc); + background: var(--btn-bg, #f5f5f5); + cursor: pointer; +} + +.growth-story-card__btn:hover { + background: var(--hover-bg, #eaeaea); +} + +.growth-story-card__btn--secondary { + background: transparent; +} diff --git a/src/components/ThoughtGrowthStoryCard.tsx b/src/components/ThoughtGrowthStoryCard.tsx new file mode 100644 index 0000000..1841dfc --- /dev/null +++ b/src/components/ThoughtGrowthStoryCard.tsx @@ -0,0 +1,207 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { GrowthStory, JourneyMilestone } from "../types/cognitiveTypes"; +import "./ThoughtGrowthStoryCard.css"; + +type Props = { + thoughtId: string; + open: boolean; + onClose: () => void; +}; + +function maturityEmoji(maturity: string): string { + switch (maturity) { + case "seedling": + return "🌱"; + case "growing": + return "🌿"; + case "mature": + return "🌳"; + default: + return "🌱"; + } +} + +function milestoneIcon(eventType: string): string { + switch (eventType) { + case "created": + return "💡"; + case "substantial-change": + return "✏️"; + case "challenge-review-pass": + return "✅"; + case "challenge-review-attempt": + return "🔄"; + case "promoted": + return "⬆️"; + default: + return "📌"; + } +} + +function generateMarkdown(story: GrowthStory): string { + const lines: string[] = [ + `## ${maturityEmoji(story.currentMaturity)} ${story.thoughtTitle} — 成长故事`, + "", + ]; + + if (story.contentPreview) { + lines.push(`> ${story.contentPreview}`, ""); + } + + lines.push( + `从 ${story.journey[0]?.date ?? story.createdAt.slice(5, 10)} 开始追踪,历时 ${story.totalDays} 天:`, + "", + ); + + for (const m of story.journey) { + lines.push(`- ${m.date} ${m.description}`); + } + + lines.push( + "", + `共经历 ${story.totalChallenges} 次挑战 · 通过率 ${Math.round(story.passRate * 100)}% · 历时 ${story.totalDays} 天`, + "", + "---", + "*Generated by KnowForge*", + ); + + return lines.join("\n"); +} + +function JourneyTimeline({ journey }: { journey: JourneyMilestone[] }) { + return ( +
    + {journey.map((m, i) => ( +
    + {milestoneIcon(m.eventType)} + {m.date} + {m.description} +
    + ))} +
    + ); +} + +export function ThoughtGrowthStoryCard({ thoughtId, open, onClose }: Props) { + const { t } = useTranslation(); + const [story, setStory] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !thoughtId) return; + let cancelled = false; + setLoading(true); + setError(null); + setStory(null); + + if (!isTauri()) { + setLoading(false); + return; + } + + invoke("get_thought_growth_story", { thoughtId }) + .then((res) => { + if (!cancelled) setStory(res); + }) + .catch((e) => { + if (!cancelled) setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [open, thoughtId]); + + const confirmExport = useCallback(async (): Promise => { + const message = t( + "growthStory.confirmExport", + "导出的内容可能包含你笔记中的部分文字,确认分享?" + ); + if (isTauri()) { + const { ask } = await import("@tauri-apps/plugin-dialog"); + return await ask(message, { title: t("growthStory.confirmExportTitle", "确认导出"), kind: "warning" }); + } + return window.confirm(message); + }, [t]); + + const handleExportMarkdown = useCallback(async () => { + if (!story) return; + const confirmed = await confirmExport(); + if (!confirmed) return; + const md = generateMarkdown(story); + const blob = new Blob([md], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `growth-story-${story.thoughtId}.md`; + a.click(); + URL.revokeObjectURL(url); + }, [story, confirmExport]); + + const handleExportImage = useCallback(async () => { + if (!story || !isTauri()) return; + const confirmed = await confirmExport(); + if (!confirmed) return; + try { + const { invoke: tauriInvoke } = await import("@tauri-apps/api/core"); + const md = generateMarkdown(story); + await tauriInvoke("export_growth_story_as_image", { + thoughtId: story.thoughtId, + markdown: md, + }); + } catch (e) { + console.error("Export image failed:", e); + } + }, [story, confirmExport]); + + if (!open) return null; + + return ( +
    +
    e.stopPropagation()}> +
    + + {t("growthStory.title", "成长故事")} + + +
    + + {loading &&
    {t("growthStory.loading", "加载中…")}
    } + + {error &&
    {error}
    } + + {story && ( +
    +
    + + {maturityEmoji(story.currentMaturity)} {story.thoughtTitle} + + + {story.totalDays}{t("growthStory.days", " 天")} · {story.totalChallenges}{t("growthStory.challenges", " 次挑战")} + +
    + + + +
    + + +
    +
    + )} +
    +
    + ); +} diff --git a/src/components/ThoughtManagementPanel.css b/src/components/ThoughtManagementPanel.css index 817c503..d244735 100644 --- a/src/components/ThoughtManagementPanel.css +++ b/src/components/ThoughtManagementPanel.css @@ -897,6 +897,20 @@ word-break: break-word; } +.thought-mgmt__detail-growth-story-btn { + background: none; + border: 1px solid var(--border-color, #ccc); + border-radius: 4px; + padding: 1px 6px; + font-size: 0.72rem; + cursor: pointer; + color: inherit; +} + +.thought-mgmt__detail-growth-story-btn:hover { + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); +} + .thought-mgmt__actions { display: flex; flex-wrap: wrap; diff --git a/src/components/ThoughtManagementPanel.tsx b/src/components/ThoughtManagementPanel.tsx index 71c0d44..1ada4cf 100644 --- a/src/components/ThoughtManagementPanel.tsx +++ b/src/components/ThoughtManagementPanel.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { ThoughtMgmtAiConversationPanel } from "./ThoughtMgmtAiConversationPanel"; import { ThoughtMgmtAiConversationToolbar } from "./ThoughtMgmtAiConversationToolbar"; +import { ThoughtGrowthStoryCard } from "./ThoughtGrowthStoryCard"; import type { ThoughtFocusContext } from "../types/aiConversation"; import type { ThoughtDetail, VaultThoughtListPage, VaultThoughtListRow } from "../types/cognitiveTypes"; import "./ThoughtManagementPanel.css"; @@ -156,6 +157,8 @@ type Props = { onOpenNote: (relPath: string) => void; /** 正文相对已加载详情是否未保存,供顶栏退出等全局逻辑使用 */ onThoughtDetailDirtyChange?: (dirty: boolean) => void; + /** 判断给定路径是否为 kf-private */ + isPathKfPrivate?: (relPath: string) => boolean; }; type DeleteThoughtResponse = { @@ -178,6 +181,7 @@ export function ThoughtManagementPanel({ tauriRuntime, onOpenNote, onThoughtDetailDirtyChange, + isPathKfPrivate, }: Props) { const { t } = useTranslation(); const [q, setQ] = useState(""); @@ -193,6 +197,7 @@ export function ThoughtManagementPanel({ const [showNew, setShowNew] = useState(false); const [newBody, setNewBody] = useState(""); const [filterMenuOpen, setFilterMenuOpen] = useState(false); + const [growthStoryOpen, setGrowthStoryOpen] = useState(false); const filterPopoverRef = useRef(null); const [listPage, setListPage] = useState(1); const [totalCount, setTotalCount] = useState(0); @@ -731,6 +736,26 @@ export function ThoughtManagementPanel({ {detail.temporary ? t("thoughtPanel.temporary") : t("thoughtManagement.flagNormal")} + + · + + {(() => { + // Check if thought is private + const isPrivate = isPathKfPrivate + && !detail.standalone + && detail.noteRelPath + && isPathKfPrivate(detail.noteRelPath); + if (isPrivate) return null; + return ( + + ); + })()}
    @@ -780,6 +805,13 @@ export function ThoughtManagementPanel({
    + {detail && ( + setGrowthStoryOpen(false)} + /> + )} ); } diff --git a/src/components/ThoughtMgmtAiConversationPanel.tsx b/src/components/ThoughtMgmtAiConversationPanel.tsx index 3b7804b..07799da 100644 --- a/src/components/ThoughtMgmtAiConversationPanel.tsx +++ b/src/components/ThoughtMgmtAiConversationPanel.tsx @@ -17,6 +17,8 @@ import { AiAssistantMarkdown } from "./AiAssistantMarkdown"; import { AiReplyContextSources } from "./AiReplyContextSources"; import { StreamingTimer } from "./StreamingTimer"; import { ThoughtSavePopover } from "./ThoughtSavePopover"; +import { useAiConfigStatus } from "../hooks/useAiConfigStatus"; +import AiNotConfiguredGuide from "./AiNotConfiguredGuide"; import type { ThoughtMgmtChatMessage } from "../hooks/useThoughtMgmtAiConversations"; import "./AiConversationPanel.css"; @@ -170,6 +172,8 @@ export function ThoughtMgmtAiConversationPanel({ setThoughtFocusContext, } = useThoughtMgmtAiConversationSession(); + const { isConfigured: aiConfigured } = useAiConfigStatus(workspaceReady); + useEffect(() => { setThoughtFocusContext(thoughtFocusFromDetail); }, [thoughtFocusFromDetail, setThoughtFocusContext]); @@ -565,6 +569,20 @@ export function ThoughtMgmtAiConversationPanel({ !isStreaming && !isVaultSearching; + if (!aiConfigured && messages.length === 0) { + return ( +
    + +
    + ); + } + return (
    void }; + +export function CognitiveReportPanel({ open, onClose }: Props) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [disabled, setDisabled] = useState(() => { + try { + return localStorage.getItem(DISABLE_KEY) === "1"; + } catch { + return false; + } + }); + const [growthStoryThoughtId, setGrowthStoryThoughtId] = useState(null); + + const load = useCallback(async () => { + if (!isTauri()) { + setError("Not available."); + return; + } + setLoading(true); + setError(null); + try { + const r = await invoke("generate_cognitive_report"); + setData(r); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (open && !disabled) void load(); + }, [open, disabled, load]); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + const toggleDisabled = useCallback((next: boolean) => { + setDisabled(next); + try { + if (next) localStorage.setItem(DISABLE_KEY, "1"); + else localStorage.removeItem(DISABLE_KEY); + } catch { /* ignore */ } + }, []); + + const handleExportGrowthStory = useCallback((thoughtId: string) => { + setGrowthStoryThoughtId(thoughtId); + }, []); + + if (!open) return null; + + return ( +
    e.target === e.currentTarget && onClose()}> +
    +
    +

    + {t("cognitiveReport.title")} +

    + +
    + +
    + {disabled ? ( +

    {t("cognitiveReport.disabledHint")}

    + ) : loading ? ( +

    {t("cognitiveReport.loading")}

    + ) : error ? ( +

    {error}

    + ) : data ? ( + <> +

    + {t("cognitiveReport.scanMeta", { files: data.scannedFiles, thoughts: data.totalThoughts })} +

    + + + + + + ) : null} +
    + +
    + + {!disabled && ( + + )} +
    +
    + setGrowthStoryThoughtId(null)} + /> +
    + ); +} diff --git a/src/components/cognitive-report/MaturityOverviewCard.tsx b/src/components/cognitive-report/MaturityOverviewCard.tsx new file mode 100644 index 0000000..4395cd4 --- /dev/null +++ b/src/components/cognitive-report/MaturityOverviewCard.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from "react-i18next"; +import type { CognitiveReportForUi } from "../../types/motivationFeedback"; + +type Props = { data: CognitiveReportForUi }; + +function delta(cur: number, prev: number): string { + const diff = cur - prev; + if (diff > 0) return `+${diff}`; + if (diff < 0) return `${diff}`; + return "0"; +} + +export function MaturityOverviewCard({ data }: Props) { + const { t } = useTranslation(); + const { seedling, growing, mature } = data.maturity; + const total = seedling + growing + mature; + + const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0); + + return ( +
    +

    {t("cognitiveReport.maturityDist")}

    + {total === 0 ? ( +

    {t("cognitiveReport.noData")}

    + ) : ( + <> +
    + {seedling > 0 && ( +
    + {pct(seedling) > 12 && {seedling}} +
    + )} + {growing > 0 && ( +
    + {pct(growing) > 12 && {growing}} +
    + )} + {mature > 0 && ( +
    + {pct(mature) > 12 && {mature}} +
    + )} +
    + +
    + + 🌱 {seedling} + + + 🌿 {growing} + + + 🌳 {mature} + +
    + + {data.prevMonthMaturity && ( +

    + {t("cognitiveReport.vsLastMonth")}: + {" 🌱 "}{delta(seedling, data.prevMonthMaturity.seedling)} + {" · 🌿 "}{delta(growing, data.prevMonthMaturity.growing)} + {" · 🌳 "}{delta(mature, data.prevMonthMaturity.mature)} +

    + )} + + )} +
    + ); +} diff --git a/src/components/cognitive-report/MonthlyTrendChart.tsx b/src/components/cognitive-report/MonthlyTrendChart.tsx new file mode 100644 index 0000000..5499ceb --- /dev/null +++ b/src/components/cognitive-report/MonthlyTrendChart.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from "react-i18next"; +import type { MonthlySnapshot } from "../../types/motivationFeedback"; + +type Props = { snapshots: MonthlySnapshot[] }; + +function monthLabel(ym: string): string { + const parts = ym.split("-"); + return parts.length === 2 ? `${parseInt(parts[1], 10)}月` : ym; +} + +export function MonthlyTrendChart({ snapshots }: Props) { + const { t } = useTranslation(); + + if (snapshots.length === 0) { + return null; + } + + const totals = snapshots.map((s) => s.seedling + s.growing + s.mature); + const maxVal = Math.max(...totals, 1); + + return ( +
    +

    {t("cognitiveReport.monthlyTrend")}

    +
    + {snapshots.map((s) => { + const total = s.seedling + s.growing + s.mature; + const hPct = (total / maxVal) * 100; + return ( +
    +
    +
    + {s.mature > 0 && ( +
    + )} + {s.growing > 0 && ( +
    + )} + {s.seedling > 0 && ( +
    + )} +
    + {total > 0 && {total}} +
    + {monthLabel(s.yearMonth)} +
    + ); + })} +
    +
    + ); +} diff --git a/src/components/cognitive-report/StatsGrid.tsx b/src/components/cognitive-report/StatsGrid.tsx new file mode 100644 index 0000000..14dd76a --- /dev/null +++ b/src/components/cognitive-report/StatsGrid.tsx @@ -0,0 +1,24 @@ +import { useTranslation } from "react-i18next"; +import type { CognitiveReportForUi } from "../../types/motivationFeedback"; + +type Props = { data: CognitiveReportForUi }; + +export function StatsGrid({ data }: Props) { + const { t } = useTranslation(); + const items = [ + { label: t("cognitiveReport.newThisMonth"), value: data.newThisMonth }, + { label: t("cognitiveReport.updatedThisMonth"), value: data.updatedThisMonth }, + { label: t("cognitiveReport.totalThoughts"), value: data.totalThoughts }, + { label: t("cognitiveReport.aiRefs"), value: data.totalAiReferences }, + ]; + return ( +
    + {items.map((it) => ( +
    +
    {it.value}
    +
    {it.label}
    +
    + ))} +
    + ); +} diff --git a/src/components/cognitive-report/ThoughtTimeline.tsx b/src/components/cognitive-report/ThoughtTimeline.tsx new file mode 100644 index 0000000..882c7bf --- /dev/null +++ b/src/components/cognitive-report/ThoughtTimeline.tsx @@ -0,0 +1,60 @@ +import { useTranslation } from "react-i18next"; +import type { CognitiveReportForUi } from "../../types/motivationFeedback"; + +type Props = { + timelines: CognitiveReportForUi["timelines"]; + onExportGrowthStory?: (thoughtId: string) => void; +}; + +export function ThoughtTimeline({ timelines, onExportGrowthStory }: Props) { + const { t } = useTranslation(); + + if (timelines.length === 0) { + return ( +
    +

    {t("cognitiveReport.topTimelines")}

    +

    {t("cognitiveReport.noData")}

    +
    + ); + } + + return ( +
    +

    {t("cognitiveReport.topTimelines")}

    +
    + {timelines.map((tl) => ( +
    +
    +
    +

    {tl.excerpt || tl.thoughtId}

    + + {tl.relPath} · {tl.history.length}{" "} + {t("cognitiveReport.entries")} + +
      + {tl.history.slice(0, 5).map((h, i) => ( +
    • + {h.date.slice(0, 10)} + {h.type} + {h.diffSummary && ( + {h.diffSummary} + )} +
    • + ))} +
    + {onExportGrowthStory && ( + + )} +
    +
    + ))} +
    +
    + ); +} diff --git a/src/hooks/useAgentEventHandlers.ts b/src/hooks/useAgentEventHandlers.ts index 25dcdd0..cf33d87 100644 --- a/src/hooks/useAgentEventHandlers.ts +++ b/src/hooks/useAgentEventHandlers.ts @@ -555,6 +555,7 @@ export function useAgentEventHandlers(deps: AgentEventDeps): AgentSessionState { conversationQuery: query, depthMode: dm, uiLocale: getAppLocale(), + thoughtId: pick.thoughtId, }, }); if (inviteSearchEpochRef.current !== epoch || disposed) return; diff --git a/src/hooks/useAiConfigStatus.ts b/src/hooks/useAiConfigStatus.ts new file mode 100644 index 0000000..1317aeb --- /dev/null +++ b/src/hooks/useAiConfigStatus.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { isTauri } from "@tauri-apps/api/core"; +import { getActiveProfile, type VaultConfigForUi } from "../types/vaultAiConfig"; +import { dispatchOpenAiSettings, VAULT_CONFIG_UPDATED_EVENT } from "../utils/vaultConfigBroadcast"; + +interface AiConfigStatus { + isConfigured: boolean; + isLoading: boolean; + openSettings: () => void; +} + +export function useAiConfigStatus(workspaceReady: boolean): AiConfigStatus { + const [isConfigured, setIsConfigured] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + const check = useCallback(async () => { + if (!isTauri() || !workspaceReady) { + setIsConfigured(false); + setIsLoading(false); + return; + } + try { + const cfg = await invoke("get_vault_config_for_ui"); + const profile = cfg.ai ? getActiveProfile(cfg.ai) : undefined; + const hasModel = !!(profile?.lastUsedModel?.trim() || profile?.defaultModel?.trim()); + const hasKey = profile?.isRemote === false || profile?.apiKeyPresent; + setIsConfigured(!!profile && !!hasKey && hasModel); + } catch { + setIsConfigured(false); + } finally { + setIsLoading(false); + } + }, [workspaceReady]); + + useEffect(() => { + check(); + const handler = () => void check(); + window.addEventListener(VAULT_CONFIG_UPDATED_EVENT, handler); + return () => window.removeEventListener(VAULT_CONFIG_UPDATED_EVENT, handler); + }, [check]); + + return { isConfigured, isLoading, openSettings: dispatchOpenAiSettings }; +} diff --git a/src/locales/en.json b/src/locales/en.json index a51c1f3..41c94dd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -385,7 +385,7 @@ "generating": "Generating deepening question...", "defaultQuestion": "Want me to explore this topic further?", "acceptedUserMessageDefault": "I'd like to explore another angle.", - "thoughtQuestion": "{{excerpt}} \u2014 Want me to dig deeper from here?", + "thoughtQuestion": "{{excerpt}} — Want me to dig deeper from here?", "snoozeConfirm": "Snooze deepening invitations. How long?", "snoozeBtn": "Not now", "snoozeDays_one": "{{count}} day", @@ -411,6 +411,9 @@ "panelNoMore": "No more items in this batch.", "endReview": "End review", "startRound": "Start challenge", + "generating": "Generating question…", + "skipItem": "Skip", + "abandon": "Give up", "submit": "Submit answer", "continueNext": "Continue to next", "dueLabel": "{{days}}d overdue", @@ -429,7 +432,28 @@ "panelEmptySecondary": "When depth is sufficient, inline challenge review may still appear at the end of AI replies.", "panelIndependentCapHint": "Raise the daily cap in AI & LLM settings (applies after save).", "openAiSettings": "Open AI settings…", - "openSourceNote": "Open source note" + "openSourceNote": "Open source note", + "feedbackPrompt": "Was this question helpful?", + "helpful": "Helpful", + "notHelpful": "Not helpful", + "feedbackThanks": "Thanks for the feedback", + "feedbackReasonHint": "What was wrong? (optional)", + "feedbackSkipReason": "Submit as-is", + "reason_too_easy": "Too easy", + "reason_irrelevant": "Irrelevant to content", + "reason_too_vague": "Too vague", + "reason_duplicate": "Repeated question", + "candidateLabel": "From \"{{file}}\"", + "reasonHighSimilarity": "Similar phrasing found in another note — worth comparing", + "reasonSemanticIsolated": "Disconnected from other notes — worth exploring", + "reasonCrossDocRecurrence": "Related concepts across multiple notes — worth organizing", + "relatedDocs": "Related notes: ", + "promotePrompt": "Is this idea worth tracking long-term?", + "promoteTrack": "Start tracking", + "promoteDismiss": "No thanks", + "promoteSkip": "Ask me later", + "promoteSuccess": "Added as a formal thought", + "promoteDismissed": "Skipped, won't recommend again" }, "thoughtSave": { "buttonTitle": "Save as thought", @@ -552,6 +576,13 @@ "challengeReviewDailyCapIndependent": "Independent review daily cap (successful completions)", "challengeReviewDailyCapInline": "Inline review daily cap (AI tab)", "challengeReviewDailyCapHint": "Each channel allows 1–20; the workspace clamps values on save.", + "cognitivePushSection": "Cognitive Review Notifications", + "cognitivePushEnable": "Enable desktop notification push", + "cognitivePushFrequency": "Push frequency", + "cognitivePushWeekly": "Weekly", + "cognitivePushMonthly": "Monthly", + "cognitivePushBoth": "Weekly + Monthly", + "cognitivePushHint": "Automatically check and push cognitive review notifications while the app is running. No weekly push when there's no review activity.", "errChallengeReviewCaps": "Review daily caps must be integers from 1 to 20.", "saving": "Saving…", "save": "Save", @@ -696,12 +727,12 @@ "updatedThisMonth": "Updated this month", "totalThoughts": "Total thoughts", "aiRefs": "Recorded AI references (rows)", - "maturityDist": "Maturity distribution (current)", - "maturityLine": "🌱 {{s}} · 🌿 {{g}} · 🌳 {{m}}", - "prevMonthLine": "Previous snapshot month: 🌱 {{s}} · 🌿 {{g}} · 🌳 {{m}}", - "noPrevMonth": "No prior month snapshot yet (saved automatically when you open this report).", - "timelines": "Sample history timelines", - "noTimelines": "No history entries to list yet.", + "maturityDist": "Maturity distribution", + "monthlyTrend": "Monthly trend", + "vsLastMonth": "vs. last month", + "noData": "No data yet", + "topTimelines": "Growth journeys", + "entries": "entries", "disableCheckbox": "Disable cognitive reports", "refresh": "Refresh" }, @@ -776,7 +807,7 @@ "step1Title": "Welcome to KnowForge", "step1Subtitle": "Let your notes help you think", "step1Desc": "KnowForge turns your Markdown notes into an active learning system. It extracts key insights as \"Thoughts\" and uses spaced repetition challenges to help you truly internalize what you've written.", - "step1Start": "Start the tour", + "step1Start": "Enter guide", "step1Skip": "Skip guide", "step2Title": "Try a challenge review", "step2Desc": "Here's a sample Thought. Read it, then try answering the challenge question below.", @@ -788,19 +819,22 @@ "step2Next": "Next", "step3Title": "Configure AI (optional)", "step3Desc": "With an AI provider configured, KnowForge can generate challenge questions for your own notes. You can always set this up later in Settings.", - "step3Skip": "Set up later", + "step3Skip": "Skip", "step3Saved": "Configuration saved!", - "step4Title": "You're all set!", - "step4Desc": "Here are three things you can do right now:", - "step4Tip1Title": "Save a thought", - "step4Tip1Desc": "Select text in the editor → click the bookmark icon", - "step4Tip2Title": "Review your thoughts", - "step4Tip2Desc": "Open the Review tab in the right panel", - "step4Tip3Title": "Quick review shortcut", - "step4Tip3Desc": "Press ⌘⇧Y / Ctrl+Shift+Y to jump to review", + "step4Title": "Discover your notes", + "step4Desc": "KnowForge is analyzing your notes to find paragraphs worth exploring.", "step4Done": "Start using KnowForge", + "discovery": { + "loading": "Reading your notes...", + "foundTitle": "Found {{count}} paragraphs worth exploring", + "foundSubtitle": "Insights from your {{docCount}} notes", + "startChallenge": "Start your first challenge", + "buildingTitle": "Analyzing your notes in the background", + "buildingDesc": "First-time analysis may take a moment. You can finish the guide and check back later", + "finishGuide": "Finish guide" + }, "stepOf": "{{current}} / {{total}}", - "prev": "Back" + "prev": "Previous" }, "activityBar": { "label": "Activity bar", @@ -810,5 +844,25 @@ "thoughts": "All thoughts", "report": "Cognitive growth report", "settings": "Settings" + }, + "aiGuide": { + "title": "{{feature}} requires an AI model", + "configure": "Configure AI Model", + "descConversation": "Configure an AI model to chat, analyze notes, and get deep insights", + "descChallengeReview": "Configure an AI model to automatically generate challenge questions from your notes", + "descSkill": "Configure an AI model to use and manage Skill extensions", + "descWritingCoach": "Configure an AI model to get writing guidance and deep thinking prompts while you write", + "descThoughtAi": "Configure an AI model to have in-depth conversations and analysis about your thoughts" + }, + "growthStory": { + "title": "Growth Story", + "loading": "Loading…", + "viewGrowthStory": "Growth Story", + "exportMarkdown": "Export Markdown", + "exportImage": "Export Image", + "days": " days", + "challenges": " challenges", + "confirmExport": "The exported content may include text from your notes. Confirm to share?", + "confirmExportTitle": "Confirm Export" } } diff --git a/src/locales/zh.json b/src/locales/zh.json index 163b34f..3bae27f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -385,7 +385,7 @@ "generating": "正在生成深化问题…", "defaultQuestion": "需要我从其他角度继续分析吗?", "acceptedUserMessageDefault": "换个角度继续聊聊。", - "thoughtQuestion": "{{excerpt}} \u2014 要不要从这里继续探讨?", + "thoughtQuestion": "{{excerpt}} — 要不要从这里继续探讨?", "snoozeConfirm": "暂停邀请后,新对话中将不再展示深化邀请。暂停多久?", "snoozeBtn": "最近不需要", "snoozeDays_one": "{{count}} 天", @@ -411,6 +411,9 @@ "panelNoMore": "本批队列已处理完毕。", "endReview": "结束回顾", "startRound": "开始挑战", + "generating": "正在生成问题…", + "skipItem": "换一个", + "abandon": "放弃本题", "submit": "提交回答", "continueNext": "继续下一条", "dueLabel": "已过期 {{days}} 天", @@ -429,7 +432,28 @@ "panelEmptySecondary": "深度足够时,仍可能在 AI 对话末尾出现内联挑战回顾。", "panelIndependentCapHint": "可在「AI 与大模型」设置中提高每日上限(保存后生效)。", "openAiSettings": "打开 AI 设置…", - "openSourceNote": "打开来源笔记" + "openSourceNote": "打开来源笔记", + "feedbackPrompt": "这个问题对你有帮助吗?", + "helpful": "有帮助", + "notHelpful": "没帮助", + "feedbackThanks": "感谢反馈", + "feedbackReasonHint": "什么问题?(可选)", + "feedbackSkipReason": "直接提交", + "reason_too_easy": "太简单了", + "reason_irrelevant": "和内容不相关", + "reason_too_vague": "问得太模糊", + "reason_duplicate": "和之前重复了", + "candidateLabel": "来自「{{file}}」", + "reasonHighSimilarity": "与其他笔记存在近似表述,值得辨析", + "reasonSemanticIsolated": "与其他笔记缺少关联,值得深入", + "reasonCrossDocRecurrence": "多篇笔记涉及相近概念,值得梳理", + "relatedDocs": "相关笔记:", + "promotePrompt": "这个想法值得长期追踪吗?", + "promoteTrack": "开始追踪", + "promoteDismiss": "不了", + "promoteSkip": "下次再问", + "promoteSuccess": "已添加为正式理解", + "promoteDismissed": "已跳过,不再推荐" }, "thoughtSave": { "buttonTitle": "保存为想法", @@ -552,6 +576,13 @@ "challengeReviewDailyCapIndependent": "独立回顾每日上限(成功计次)", "challengeReviewDailyCapInline": "对话内联回顾每日上限", "challengeReviewDailyCapHint": "每条通道允许 1–20;保存时由工作区配置自动钳制。", + "cognitivePushSection": "认知回顾推送", + "cognitivePushEnable": "启用桌面通知推送", + "cognitivePushFrequency": "推送频率", + "cognitivePushWeekly": "每周", + "cognitivePushMonthly": "每月", + "cognitivePushBoth": "每周 + 每月", + "cognitivePushHint": "应用运行时自动检查并推送认知回顾通知。无复习活动时不推送周报。", "errChallengeReviewCaps": "回顾每日上限必须为 1–20 的整数。", "saving": "正在保存…", "save": "保存", @@ -696,12 +727,12 @@ "updatedThisMonth": "本月修改", "totalThoughts": "理解总数", "aiRefs": "已记录的 AI 引用条数", - "maturityDist": "成熟度分布(当前)", - "maturityLine": "🌱 {{s}} · 🌿 {{g}} · 🌳 {{m}}", - "prevMonthLine": "上一快照月:🌱 {{s}} · 🌿 {{g}} · 🌳 {{m}}", - "noPrevMonth": "尚无上一月快照(打开本报告时会自动保存当月分布)。", - "timelines": "示例历史时间线", - "noTimelines": "暂无可展示的历史条目。", + "maturityDist": "成熟度分布", + "monthlyTrend": "月度趋势", + "vsLastMonth": "较上月", + "noData": "暂无数据", + "topTimelines": "成长历程", + "entries": "条记录", "disableCheckbox": "停用认知报告", "refresh": "刷新" }, @@ -776,7 +807,7 @@ "step1Title": "欢迎来到 KnowForge", "step1Subtitle": "让你的笔记帮你思考", "step1Desc": "KnowForge 将你的 Markdown 笔记变成主动学习系统。它把关键洞察提取为「想法」,并通过间隔挑战复习帮你真正内化所学内容。", - "step1Start": "开始体验", + "step1Start": "进入引导", "step1Skip": "跳过引导", "step2Title": "试试挑战复习", "step2Desc": "这是一条示例想法。阅读后,试着回答下面的挑战问题。", @@ -788,19 +819,22 @@ "step2Next": "下一步", "step3Title": "配置 AI(可选)", "step3Desc": "配置 AI 服务后,KnowForge 可以对你自己的笔记生成挑战问题。你随时可以在设置中配置。", - "step3Skip": "稍后再说", + "step3Skip": "跳过", "step3Saved": "配置已保存!", - "step4Title": "你已准备好", - "step4Desc": "现在你可以做这三件事:", - "step4Tip1Title": "保存想法", - "step4Tip1Desc": "在编辑器中选中文字 → 点击书签图标", - "step4Tip2Title": "复习想法", - "step4Tip2Desc": "打开右侧面板的「复习」标签", - "step4Tip3Title": "快捷复习", - "step4Tip3Desc": "按 ⌘⇧Y / Ctrl+Shift+Y 快速跳转", + "step4Title": "发现你的笔记", + "step4Desc": "KnowForge 正在分析你的笔记,寻找值得深入思考的段落。", "step4Done": "开始使用 KnowForge", + "discovery": { + "loading": "正在阅读你的笔记...", + "foundTitle": "发现 {{count}} 个值得深入思考的段落", + "foundSubtitle": "来自你 {{docCount}} 篇笔记中的观点和想法", + "startChallenge": "开始第一次思考挑战", + "buildingTitle": "正在后台分析你的笔记", + "buildingDesc": "首次分析需要一些时间,你可以先完成引导,稍后再来查看", + "finishGuide": "完成引导" + }, "stepOf": "{{current}} / {{total}}", - "prev": "返回" + "prev": "上一步" }, "activityBar": { "label": "活动栏", @@ -810,5 +844,25 @@ "thoughts": "全部想法", "report": "认知成长报告", "settings": "设置" + }, + "aiGuide": { + "title": "{{feature}} 需要 AI 模型", + "configure": "配置 AI 模型", + "descConversation": "配置 AI 模型后,可与 AI 对话、分析笔记、获取深度洞见", + "descChallengeReview": "配置 AI 模型后,系统将根据你的笔记自动生成挑战问题", + "descSkill": "配置 AI 模型后,可使用和管理 Skill 扩展能力", + "descWritingCoach": "配置 AI 模型后,写作教练将在你书写时提供深度思考引导", + "descThoughtAi": "配置 AI 模型后,可与 AI 就想法进行深入对话和分析" + }, + "growthStory": { + "title": "成长故事", + "loading": "加载中…", + "viewGrowthStory": "成长故事", + "exportMarkdown": "导出 Markdown", + "exportImage": "导出图片", + "days": " 天", + "challenges": " 次挑战", + "confirmExport": "导出的内容可能包含你笔记中的部分文字,确认分享?", + "confirmExportTitle": "确认导出" } } diff --git a/src/types/cognitiveTypes.ts b/src/types/cognitiveTypes.ts index 743d9d0..98023f2 100644 --- a/src/types/cognitiveTypes.ts +++ b/src/types/cognitiveTypes.ts @@ -60,6 +60,12 @@ export type CognitiveConfigForUi = { writingCoachBubbleSeconds: number; /** 忽略气泡后的冷却分钟数(默认 15) */ writingCoachCooldownMinutes: number; + /** 认知回顾推送总开关(默认关) */ + cognitivePushEnabled: boolean; + /** 推送频率:"weekly" | "monthly" | "both"(默认 both) */ + cognitivePushFrequency: string; + /** 上次推送时间(ISO 8601) */ + cognitivePushLastSent?: string; }; // --- 认知配置保存载荷(对齐 CognitiveConfigPatch) --- @@ -88,6 +94,9 @@ export type CognitiveConfigSavePatch = { writingCoachTermMinChars?: number; writingCoachBubbleSeconds?: number; writingCoachCooldownMinutes?: number; + cognitivePushEnabled?: boolean; + cognitivePushFrequency?: string; + cognitivePushLastSent?: string | null; }; // --- 理解区块(解析结果) --- @@ -233,6 +242,9 @@ export type GenerateChallengeQuestionArgs = { depthMode?: DepthMode; /** 与 Knowforge 设置一致:`en` | `zh`,驱动模型输出自然语言 */ uiLocale?: "en" | "zh"; + markingReason?: string; + pairedExcerpt?: string; + thoughtId?: string; }; /** `evaluate_challenge_answer` 请求 */ @@ -260,6 +272,29 @@ export type EvaluateChallengeAnswerResponse = { templateKind?: string; }; +/** `get_feedback_stats` 响应 */ +export type FeedbackTemplateStats = { + template: string; + total: number; + helpful: number; + notHelpful: number; + helpfulRate: number; +}; + +export type FeedbackIssueCount = { + reason: string; + count: number; +}; + +export type FeedbackStats = { + totalRatings: number; + helpfulCount: number; + notHelpfulCount: number; + helpfulRate: number; + byTemplate: FeedbackTemplateStats[]; + commonIssues: FeedbackIssueCount[]; +}; + /** `count_vault_thoughts_for_review` 响应 */ export type CountVaultThoughtsForReviewResponse = { totalThoughts: number; @@ -278,6 +313,11 @@ export type ReviewQueueItem = { nextDueAt: string; overdueDays: number; privateOmitted: boolean; + sourceType: "thought" | "candidate"; + candidateId?: string; + markingReason?: string; + pairedExcerpt?: string; + startLine?: number; }; export type ListReviewQueueResponse = { @@ -286,3 +326,24 @@ export type ListReviewQueueResponse = { totalDue: number; meta: SearchThoughtMetaForUi; }; + +// --- 成长故事导出 --- + +export type JourneyMilestone = { + date: string; + eventType: string; + description: string; +}; + +export type GrowthStory = { + thoughtId: string; + thoughtTitle: string; + contentPreview: string; + sourceFile: string; + createdAt: string; + currentMaturity: string; + journey: JourneyMilestone[]; + totalChallenges: number; + totalDays: number; + passRate: number; +}; diff --git a/src/types/motivationFeedback.ts b/src/types/motivationFeedback.ts index ad712da..bd4ed89 100644 --- a/src/types/motivationFeedback.ts +++ b/src/types/motivationFeedback.ts @@ -10,6 +10,13 @@ export type ThoughtMaturityChangedPayload = { startLine: number; }; +export type MonthlySnapshot = { + yearMonth: string; + seedling: number; + growing: number; + mature: number; +}; + export type CognitiveReportForUi = { scannedFiles: number; totalThoughts: number; @@ -32,4 +39,5 @@ export type CognitiveReportForUi = { excerpt: string; history: KfThoughtHistoryEntry[]; }>; + monthlySnapshots: MonthlySnapshot[]; };