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
}>
- {}}
- 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