From 8e3805395bfe5d74d410ee8b20281e70c21137fa Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Thu, 9 Jul 2026 14:43:53 +0800
Subject: [PATCH 01/19] feat(srs): upgrade challenge scheduling to SM-2
algorithm
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace hardcoded [1,3,7,14,30] day intervals with the SM-2 spaced
repetition algorithm. EF and interval are now persisted per thought
and adjusted by answer quality (Passed/Sloppy/Failed).
- Add SrsState, ChallengeQuality, sm2_next() in thought_parser
- DB migration v2→v3: srs_easiness_factor, srs_interval_days columns
- Rewrite next_due_after_anchor() to use SM-2 interval with legacy fallback
- Sort review queue by EF ascending (harder first)
- Frontend: call write-back on all outcomes, pass sloppy flag
- 6 new SM-2 unit tests
---
src-tauri/src/challenge_review.rs | 81 ++++++--
src-tauri/src/thought_parser.rs | 231 +++++++++++++++++++++--
src-tauri/src/thought_retrieval.rs | 39 ++--
src-tauri/src/vault_thoughts_db.rs | 86 ++++++---
src/components/ChallengeReviewInline.tsx | 15 +-
src/components/ChallengeReviewPanel.tsx | 15 +-
6 files changed, 373 insertions(+), 94 deletions(-)
diff --git a/src-tauri/src/challenge_review.rs b/src-tauri/src/challenge_review.rs
index ea69839..a67bcdd 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);
@@ -532,8 +546,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";
@@ -635,7 +649,7 @@ fn list_review_queue_blocking(canonical_root: &Path) -> Result today {
@@ -647,7 +661,16 @@ 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))
}
@@ -819,24 +846,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/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_thoughts_db.rs b/src-tauri/src/vault_thoughts_db.rs
index 410c2ee..27ea77c 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,7 @@ fn init_schema(conn: &Connection) -> Result<(), String> {
.map_err(|e| format!("初始化 thoughts 表失败: {e}"))?;
migrate_v1_to_v2(conn)?;
+ migrate_v2_to_v3(conn)?;
let ver: i32 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
@@ -205,13 +226,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 +244,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 +281,43 @@ pub fn graph_thought_stats(conn: &Connection) -> Result
}
/// 回顾排期:侧车行 + 元数据列(YAML 不再扫 callout);不含独立想法
+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/components/ChallengeReviewInline.tsx b/src/components/ChallengeReviewInline.tsx
index 9e2ee8a..8d9aad5 100644
--- a/src/components/ChallengeReviewInline.tsx
+++ b/src/components/ChallengeReviewInline.tsx
@@ -60,14 +60,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 {
diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx
index d7f3612..2f843e5 100644
--- a/src/components/ChallengeReviewPanel.tsx
+++ b/src/components/ChallengeReviewPanel.tsx
@@ -135,14 +135,15 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
passed: ev.passed,
sloppy: ev.sloppy,
});
+ await invoke("apply_challenge_pass_to_thought", {
+ args: {
+ relPath: currentItem.relPath,
+ thoughtId: currentItem.thoughtId,
+ passed: ev.passed && !ev.sloppy,
+ sloppy: ev.sloppy,
+ },
+ });
if (ev.passed && !ev.sloppy) {
- await invoke("apply_challenge_pass_to_thought", {
- args: {
- relPath: currentItem.relPath,
- thoughtId: currentItem.thoughtId,
- passed: true,
- },
- });
await freqCtrl.recordChallengeIndependentShown(currentItem.thoughtId);
await freqCtrl.reload();
if (!freqCtrl.canStartMoreIndependentReviewsToday()) {
From a408882c386ddef6d8d7146648e282c2052048cf Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Thu, 9 Jul 2026 16:04:50 +0800
Subject: [PATCH 02/19] feat(ai-guide): show setup guidance when LLM is not
configured
Also fix settings modal to allow clearing API key.
Co-authored-by: Claude (Opus 4.6)
---
src/components/AiConversationPanel.tsx | 16 ++++++--
src/components/AiLlmSettingsModal.tsx | 2 +-
src/components/AiNotConfiguredGuide.css | 48 +++++++++++++++++++++++
src/components/AiNotConfiguredGuide.tsx | 52 +++++++++++++++++++++++++
src/components/ChallengeReviewPanel.tsx | 16 ++++----
src/components/SkillManagementPanel.tsx | 10 +++++
src/hooks/useAiConfigStatus.ts | 44 +++++++++++++++++++++
src/locales/en.json | 7 ++++
src/locales/zh.json | 7 ++++
9 files changed, 191 insertions(+), 11 deletions(-)
create mode 100644 src/components/AiNotConfiguredGuide.css
create mode 100644 src/components/AiNotConfiguredGuide.tsx
create mode 100644 src/hooks/useAiConfigStatus.ts
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) => (
+
+
+
+
+ );
+}
+
+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/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx
index 2f843e5..b577225 100644
--- a/src/components/ChallengeReviewPanel.tsx
+++ b/src/components/ChallengeReviewPanel.tsx
@@ -17,6 +17,8 @@ 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 "./ChallengeReviewPanel.css";
@@ -28,6 +30,7 @@ type Props = {
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);
@@ -227,7 +230,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
);
- if (!independent) {
+ if (!independent || !aiConfigured) {
return (
@@ -236,12 +239,11 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
{t("challengeReview.close")}
-
{t("challengeReview.panelNeedsLlm")}
-
-
-
+
);
}
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/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..44e8a38 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -810,5 +810,12 @@
"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"
}
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 163b34f..e0c2bbd 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -810,5 +810,12 @@
"thoughts": "全部想法",
"report": "认知成长报告",
"settings": "设置"
+ },
+ "aiGuide": {
+ "title": "{{feature}} 需要 AI 模型",
+ "configure": "配置 AI 模型",
+ "descConversation": "配置 AI 模型后,可与 AI 对话、分析笔记、获取深度洞见",
+ "descChallengeReview": "配置 AI 模型后,系统将根据你的笔记自动生成挑战问题",
+ "descSkill": "配置 AI 模型后,可使用和管理 Skill 扩展能力"
}
}
From 3e59153f60ed783d5691aa4cdd7abda801c462fb Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Thu, 9 Jul 2026 16:44:30 +0800
Subject: [PATCH 03/19] feat(latent-paragraphs): add embedding-based paragraph
candidate engine
Pure-backend latent paragraph marking with three strategies:
high-similarity pairs, semantic isolation, cross-doc recurrence.
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/latent_paragraphs.rs | 831 +++++++++++++++++++++++++++++
src-tauri/src/lib.rs | 19 +-
src-tauri/src/semantic_index.rs | 30 ++
3 files changed, 879 insertions(+), 1 deletion(-)
create mode 100644 src-tauri/src/latent_paragraphs.rs
diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs
new file mode 100644
index 0000000..8c5f03b
--- /dev/null
+++ b/src-tauri/src/latent_paragraphs.rs
@@ -0,0 +1,831 @@
+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;
+
+#[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)]
+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);
+ "#,
+ )
+ .map_err(|e| format!("init thought_candidates schema: {e}"))?;
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// 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_quote_block(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();
+ trimmed.starts_with("```") && trimmed.ends_with("```") && trimmed.matches("```").count() >= 2
+}
+
+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("> "))
+}
+
+// ---------------------------------------------------------------------------
+// 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 {
+ 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: None,
+ });
+ }
+ }
+ }
+
+ // 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(())
+}
+
+// ---------------------------------------------------------------------------
+// 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));
+ }
+
+ #[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"
+ );
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 6ddb5d6..1652205 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -32,6 +32,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;
@@ -1706,6 +1707,21 @@ async fn add_manual_topic_semantic(
}
#[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())?
+}
+
pub fn run() {
tauri::Builder::default()
.manage(WorkspaceState::default())
@@ -1802,7 +1818,8 @@ 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
])
.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}");
+ }
+ }
+ }
+ });
}
}
From dd94dead285b8d3b924680e4b27c5d1ac3c195d3 Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Thu, 9 Jul 2026 18:16:27 +0800
Subject: [PATCH 04/19] feat(onboarding): replace step 4 tips with dynamic
discovery card
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/latent_paragraphs.rs | 53 ++++++-
src-tauri/src/lib.rs | 19 ++-
src/App.tsx | 5 +
src/components/OnboardingDiscoveryCard.css | 110 +++++++++++++++
src/components/OnboardingDiscoveryCard.tsx | 154 +++++++++++++++++++++
src/components/OnboardingOverlay.tsx | 42 ++----
src/locales/en.json | 25 ++--
src/locales/zh.json | 25 ++--
8 files changed, 375 insertions(+), 58 deletions(-)
create mode 100644 src/components/OnboardingDiscoveryCard.css
create mode 100644 src/components/OnboardingDiscoveryCard.tsx
diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs
index 8c5f03b..d98d3ec 100644
--- a/src-tauri/src/latent_paragraphs.rs
+++ b/src-tauri/src/latent_paragraphs.rs
@@ -86,6 +86,9 @@ pub fn should_skip_chunk(text: &str) -> bool {
if is_code_block(trimmed) {
return true;
}
+ if is_code_heavy(trimmed) {
+ return true;
+ }
if is_quote_block(trimmed) {
return true;
}
@@ -112,7 +115,47 @@ fn is_pure_list(text: &str) -> bool {
fn is_code_block(text: &str) -> bool {
let trimmed = text.trim();
- trimmed.starts_with("```") && trimmed.ends_with("```") && trimmed.matches("```").count() >= 2
+ 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 {
@@ -652,6 +695,14 @@ mod tests {
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]
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 1652205..98b91ed 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1722,6 +1722,22 @@ async fn list_latent_candidates(
.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())?
+}
+
pub fn run() {
tauri::Builder::default()
.manage(WorkspaceState::default())
@@ -1819,7 +1835,8 @@ pub fn run() {
skills::commands::reload_custom_skills,
skills::commands::list_available_tools,
onboarding::seed_onboarding_content,
- list_latent_candidates
+ list_latent_candidates,
+ trigger_latent_scan
])
.setup(|app| {
use tauri::Manager;
diff --git a/src/App.tsx b/src/App.tsx
index c28e3b7..9fbe94d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1773,6 +1773,11 @@ function App() {
setOnboardingOpen(false)}
+ onStartChallenge={() => {
+ setOnboardingOpen(false);
+ localStorage.setItem("knowforge:onboardingCompleted", "true");
+ requestOpenChallengeReview();
+ }}
tauriRuntime={tauriRuntime}
/>
diff --git a/src/components/OnboardingDiscoveryCard.css b/src/components/OnboardingDiscoveryCard.css
new file mode 100644
index 0000000..cae2fab
--- /dev/null
+++ b/src/components/OnboardingDiscoveryCard.css
@@ -0,0 +1,110 @@
+.discovery-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 12px;
+ padding: 8px 0;
+}
+
+/* loading pulse */
+.discovery-card__loading {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ justify-content: center;
+ padding: 24px 0;
+}
+
+.discovery-card__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--color-accent, #4f9cf7);
+ animation: discovery-pulse 1.2s ease-in-out infinite;
+}
+
+.discovery-card__dot:nth-child(2) { animation-delay: 0.2s; }
+.discovery-card__dot:nth-child(3) { animation-delay: 0.4s; }
+
+@keyframes discovery-pulse {
+ 0%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 50% { opacity: 1; transform: scale(1.1); }
+}
+
+.discovery-card__loading-text {
+ margin-top: 8px;
+ font-size: 13px;
+ color: var(--color-text-secondary, #666);
+}
+
+/* found state */
+.discovery-card__count {
+ font-size: 40px;
+ font-weight: 700;
+ color: var(--color-accent, #4f9cf7);
+ line-height: 1;
+}
+
+.discovery-card__found-title {
+ margin: 0;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--color-text, #333);
+}
+
+.discovery-card__found-subtitle {
+ margin: 0;
+ font-size: 13px;
+ color: var(--color-text-secondary, #666);
+}
+
+/* preview list */
+.discovery-card__previews {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ width: 100%;
+ margin-top: 4px;
+ text-align: left;
+}
+
+.discovery-card__preview-item {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 12px;
+ background: var(--color-bg-secondary, #f7f7f8);
+ border-radius: 6px;
+ font-size: 12.5px;
+ line-height: 1.4;
+}
+
+.discovery-card__preview-excerpt {
+ color: var(--color-text, #333);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.discovery-card__preview-source {
+ color: var(--color-text-secondary, #999);
+ font-size: 11px;
+}
+
+/* empty state */
+.discovery-card__empty-title {
+ margin: 0;
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text, #333);
+}
+
+.discovery-card__empty-desc {
+ margin: 0;
+ font-size: 13px;
+ color: var(--color-text-secondary, #666);
+ max-width: 320px;
+ line-height: 1.5;
+}
diff --git a/src/components/OnboardingDiscoveryCard.tsx b/src/components/OnboardingDiscoveryCard.tsx
new file mode 100644
index 0000000..d567972
--- /dev/null
+++ b/src/components/OnboardingDiscoveryCard.tsx
@@ -0,0 +1,154 @@
+import { useEffect, useRef, useState } from "react";
+import { invoke } from "@tauri-apps/api/core";
+import { useTranslation } from "react-i18next";
+import "./OnboardingDiscoveryCard.css";
+
+interface CandidateForUi {
+ id: string;
+ relPath: string;
+ excerpt: string;
+ markingReason: string;
+ similarityScore: number | null;
+ pairedRelPath: string | null;
+ startLine: number;
+ endLine: number;
+}
+
+type Phase = "loading" | "found" | "building";
+
+interface Props {
+ tauriRuntime: boolean;
+ onStartChallenge: () => void;
+ onFinish: () => void;
+}
+
+const PREVIEW_COUNT = 3;
+const SCAN_TIMEOUT_MS = 8000;
+
+export default function OnboardingDiscoveryCard({
+ tauriRuntime,
+ onStartChallenge,
+ onFinish,
+}: Props) {
+ const { t } = useTranslation();
+ const [phase, setPhase] = useState("loading");
+ const [candidates, setCandidates] = useState([]);
+ const mountedRef = useRef(true);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ if (!tauriRuntime) {
+ setPhase("building");
+ return;
+ }
+
+ void (async () => {
+ // 1. Check existing candidates (instant)
+ try {
+ const existing = await invoke("list_latent_candidates");
+ if (!mountedRef.current) return;
+ if (existing.length > 0) {
+ setCandidates(existing);
+ setPhase("found");
+ return;
+ }
+ } catch {
+ // DB may not exist
+ }
+ if (!mountedRef.current) return;
+
+ // 2. Try scan with timeout — only succeeds if index already built
+ try {
+ const scanned = await Promise.race([
+ invoke("trigger_latent_scan"),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error("timeout")), SCAN_TIMEOUT_MS),
+ ),
+ ]);
+ if (!mountedRef.current) return;
+ if (scanned && scanned.length > 0) {
+ setCandidates(scanned);
+ setPhase("found");
+ return;
+ }
+ } catch {
+ // scan failed or timed out
+ }
+ if (!mountedRef.current) return;
+
+ // 3. Index not ready — kick off rebuild in background, don't wait
+ setPhase("building");
+ invoke("rebuild_embeddings", { resume: false }).catch(() => {});
+ })();
+
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [tauriRuntime]);
+
+ if (phase === "loading") {
+ return (
+
+
+
+
+
+
+
+ {t("onboarding.discovery.loading")}
+
+
+ );
+ }
+
+ if (phase === "building") {
+ return (
+
+
+ {t("onboarding.discovery.buildingTitle")}
+
+
+ {t("onboarding.discovery.buildingDesc")}
+
+
+
+
+
+ );
+ }
+
+ 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/locales/en.json b/src/locales/en.json
index 44e8a38..7bd214c 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -776,7 +776,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 +788,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",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index e0c2bbd..8f93fde 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -776,7 +776,7 @@
"step1Title": "欢迎来到 KnowForge",
"step1Subtitle": "让你的笔记帮你思考",
"step1Desc": "KnowForge 将你的 Markdown 笔记变成主动学习系统。它把关键洞察提取为「想法」,并通过间隔挑战复习帮你真正内化所学内容。",
- "step1Start": "开始体验",
+ "step1Start": "进入引导",
"step1Skip": "跳过引导",
"step2Title": "试试挑战复习",
"step2Desc": "这是一条示例想法。阅读后,试着回答下面的挑战问题。",
@@ -788,19 +788,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": "活动栏",
From 5ade8ad153604710c3573a2f92b230a52f16b321 Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Thu, 9 Jul 2026 19:46:22 +0800
Subject: [PATCH 05/19] chore(ui): freeze graph, topic-network and skills
modules
Co-authored-by: Claude (Opus 4.6)
---
src/App.tsx | 15 +-------
src/components/ActivityBar.tsx | 25 +-------------
src/components/AiLlmSettingsModal.tsx | 50 ++++-----------------------
3 files changed, 8 insertions(+), 82 deletions(-)
diff --git a/src/App.tsx b/src/App.tsx
index 9fbe94d..58049d6 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -42,7 +42,6 @@ import { ThoughtSavePopover } from "./components/ThoughtSavePopover";
import { ThoughtVaultHubModal } from "./components/ThoughtVaultHubModal";
import { WorkspaceSearchModal } from "./components/WorkspaceSearchModal";
import { EditorFindBar } from "./components/EditorFindBar";
-import { GraphTabShell } from "./components/GraphTabShell";
import { ActivityBar, type LeftPanelView } from "./components/ActivityBar";
import { OnboardingOverlay } from "./components/OnboardingOverlay";
import { KF_PRIVATE_LOCK_ICON_DOC_BAR_PX } from "./constants/kfPrivateUi";
@@ -1333,19 +1332,7 @@ function App() {
- {leftPanelView === "graph" ? (
-
- {
- setLeftPanelView("files");
- void onOpenCoachMarkdownPath(relPath);
- }}
- />
-
- ) : leftPanelView === "thoughts" ? (
+ {leftPanelView === "thoughts" ? (
thoughtManagementSessionActive ? (
-
-
-
-
-
- );
-}
-
function ThoughtsIcon() {
return (
- ) : (
- {t("settings.loading")}
}>
- {}}
- embedded={true}
- workspaceReady={workspaceReady}
- tauriRuntime={tauriRuntime}
- dragExcludeProps={dragExcludeProps}
- />
-
- )}
+ ) : null}
From ecdb2b2974587eb7350ffbfc169054b343e1f69e Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Thu, 9 Jul 2026 20:19:09 +0800
Subject: [PATCH 06/19] refactor(cognitive-report): split panel into card-based
subcomponents
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/cognitive_report.rs | 25 ++
src/App.tsx | 2 +-
src/components/CognitiveReportPanel.css | 117 -------
src/components/CognitiveReportPanel.tsx | 184 -----------
.../cognitive-report/CognitiveReportPanel.css | 293 ++++++++++++++++++
.../cognitive-report/CognitiveReportPanel.tsx | 121 ++++++++
.../cognitive-report/MaturityOverviewCard.tsx | 81 +++++
.../cognitive-report/MonthlyTrendChart.tsx | 60 ++++
src/components/cognitive-report/StatsGrid.tsx | 24 ++
.../cognitive-report/ThoughtTimeline.tsx | 48 +++
src/locales/en.json | 12 +-
src/locales/zh.json | 12 +-
src/types/motivationFeedback.ts | 8 +
13 files changed, 673 insertions(+), 314 deletions(-)
delete mode 100644 src/components/CognitiveReportPanel.css
delete mode 100644 src/components/CognitiveReportPanel.tsx
create mode 100644 src/components/cognitive-report/CognitiveReportPanel.css
create mode 100644 src/components/cognitive-report/CognitiveReportPanel.tsx
create mode 100644 src/components/cognitive-report/MaturityOverviewCard.tsx
create mode 100644 src/components/cognitive-report/MonthlyTrendChart.tsx
create mode 100644 src/components/cognitive-report/StatsGrid.tsx
create mode 100644 src/components/cognitive-report/ThoughtTimeline.tsx
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 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}
-
-
-
-
- );
-}
diff --git a/src/components/cognitive-report/CognitiveReportPanel.css b/src/components/cognitive-report/CognitiveReportPanel.css
new file mode 100644
index 0000000..e330f9a
--- /dev/null
+++ b/src/components/cognitive-report/CognitiveReportPanel.css
@@ -0,0 +1,293 @@
+/* ---------- Backdrop & modal shell ---------- */
+.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(660px, 100%);
+ max-height: min(86vh, 760px);
+ 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: 14px 16px 18px;
+ overflow-y: auto;
+ font-size: 14px;
+ line-height: 1.5;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.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;
+}
+
+/* ---------- Shared ---------- */
+.cr-section-title {
+ margin: 0 0 8px;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.cr-muted {
+ opacity: 0.65;
+ font-size: 13px;
+ margin: 0;
+}
+
+/* ---------- StatsGrid ---------- */
+.cr-stats {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
+ gap: 10px;
+ margin: 0;
+}
+
+.cr-stats__card {
+ padding: 10px 12px;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--kf-text, #fff) 5%, transparent);
+}
+
+.cr-stats__value {
+ margin: 0;
+ font-size: 22px;
+ font-weight: 700;
+}
+
+.cr-stats__label {
+ margin: 2px 0 0;
+ font-size: 12px;
+ opacity: 0.7;
+}
+
+/* ---------- MaturityOverviewCard ---------- */
+.cr-maturity__bar {
+ display: flex;
+ height: 22px;
+ border-radius: 6px;
+ overflow: hidden;
+ margin: 8px 0 10px;
+}
+
+.cr-maturity__seg {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 11px;
+ font-weight: 600;
+ color: #fff;
+ min-width: 2px;
+}
+
+.cr-maturity__seg--seedling { background: #84cc16; }
+.cr-maturity__seg--growing { background: #22c55e; }
+.cr-maturity__seg--mature { background: #15803d; }
+
+.cr-maturity__legend {
+ display: flex;
+ gap: 14px;
+ font-size: 13px;
+}
+
+.cr-maturity__legend-item {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+}
+
+.cr-maturity__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ display: inline-block;
+}
+
+.cr-maturity__dot--seedling { background: #84cc16; }
+.cr-maturity__dot--growing { background: #22c55e; }
+.cr-maturity__dot--mature { background: #15803d; }
+
+.cr-maturity__delta {
+ margin-top: 6px;
+}
+
+/* ---------- MonthlyTrendChart ---------- */
+.cr-trend__chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+ height: 120px;
+ margin-top: 8px;
+}
+
+.cr-trend__col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+}
+
+.cr-trend__bar-wrap {
+ flex: 1;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+}
+
+.cr-trend__bar {
+ width: 70%;
+ border-radius: 4px 4px 0 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.cr-trend__seg {
+ min-height: 2px;
+}
+
+.cr-trend__seg--seedling { background: #84cc16; }
+.cr-trend__seg--growing { background: #22c55e; }
+.cr-trend__seg--mature { background: #15803d; }
+
+.cr-trend__count {
+ font-size: 10px;
+ margin-bottom: 2px;
+ opacity: 0.7;
+}
+
+.cr-trend__label {
+ font-size: 11px;
+ opacity: 0.65;
+ margin-top: 4px;
+}
+
+/* ---------- ThoughtTimeline ---------- */
+.cr-timeline__list {
+ position: relative;
+ padding-left: 18px;
+ border-left: 2px solid color-mix(in srgb, var(--kf-border, #444) 70%, transparent);
+}
+
+.cr-timeline__item {
+ position: relative;
+ padding: 4px 0 14px;
+}
+
+.cr-timeline__item:last-child {
+ padding-bottom: 0;
+}
+
+.cr-timeline__dot {
+ position: absolute;
+ left: -24px;
+ top: 8px;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: #22c55e;
+ border: 2px solid var(--kf-panel-bg, #1a1a1a);
+}
+
+.cr-timeline__content {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.cr-timeline__excerpt {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.cr-timeline__meta {
+ font-size: 11px;
+ opacity: 0.6;
+}
+
+.cr-timeline__history {
+ margin: 4px 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 12px;
+}
+
+.cr-timeline__entry {
+ display: flex;
+ gap: 6px;
+ padding: 1px 0;
+ opacity: 0.75;
+}
+
+.cr-timeline__date {
+ flex-shrink: 0;
+}
+
+.cr-timeline__type {
+ font-weight: 500;
+}
+
+.cr-timeline__diff {
+ opacity: 0.7;
+}
diff --git a/src/components/cognitive-report/CognitiveReportPanel.tsx b/src/components/cognitive-report/CognitiveReportPanel.tsx
new file mode 100644
index 0000000..b5c7e61
--- /dev/null
+++ b/src/components/cognitive-report/CognitiveReportPanel.tsx
@@ -0,0 +1,121 @@
+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 { StatsGrid } from "./StatsGrid";
+import { MaturityOverviewCard } from "./MaturityOverviewCard";
+import { MonthlyTrendChart } from "./MonthlyTrendChart";
+import { ThoughtTimeline } from "./ThoughtTimeline";
+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 })}
+
+
+
+
+
+ >
+ ) : 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..d599984
--- /dev/null
+++ b/src/components/cognitive-report/ThoughtTimeline.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from "react-i18next";
+import type { CognitiveReportForUi } from "../../types/motivationFeedback";
+
+type Props = { timelines: CognitiveReportForUi["timelines"] };
+
+export function ThoughtTimeline({ timelines }: 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}
+ )}
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/locales/en.json b/src/locales/en.json
index 7bd214c..a12a8d3 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -696,12 +696,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"
},
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 8f93fde..f0736b9 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -696,12 +696,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": "刷新"
},
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[];
};
From 758ca0255f0bd7dd1985bd10675517c6a2b77bc6 Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Fri, 10 Jul 2026 11:02:28 +0800
Subject: [PATCH 07/19] feat(challenge): add question quality feedback loop
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/challenge_feedback.rs | 268 +++++++++++++++++++++++
src-tauri/src/lib.rs | 3 +
src-tauri/src/vault_thoughts_db.rs | 1 +
src/components/ChallengeFeedbackBar.tsx | 95 ++++++++
src/components/ChallengeReviewInline.tsx | 6 +
src/components/ChallengeReviewPanel.css | 93 ++++++++
src/components/ChallengeReviewPanel.tsx | 8 +
src/locales/en.json | 12 +-
src/locales/zh.json | 12 +-
src/types/cognitiveTypes.ts | 23 ++
10 files changed, 519 insertions(+), 2 deletions(-)
create mode 100644 src-tauri/src/challenge_feedback.rs
create mode 100644 src/components/ChallengeFeedbackBar.tsx
diff --git a/src-tauri/src/challenge_feedback.rs b/src-tauri/src/challenge_feedback.rs
new file mode 100644
index 0000000..cffa25a
--- /dev/null
+++ b/src-tauri/src/challenge_feedback.rs
@@ -0,0 +1,268 @@
+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);
+ "#,
+ )
+ .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,
+ })
+}
+
+#[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 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/lib.rs b/src-tauri/src/lib.rs
index 98b91ed..aebf3e9 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -13,6 +13,7 @@ use std::time::{Duration, Instant, UNIX_EPOCH};
use tauri::{AppHandle, Emitter};
mod ai_conversations;
+mod challenge_feedback;
mod challenge_review;
mod depth_decisions;
mod cognitive_report;
@@ -1810,6 +1811,8 @@ pub fn run() {
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,
diff --git a/src-tauri/src/vault_thoughts_db.rs b/src-tauri/src/vault_thoughts_db.rs
index 27ea77c..46ba641 100644
--- a/src-tauri/src/vault_thoughts_db.rs
+++ b/src-tauri/src/vault_thoughts_db.rs
@@ -96,6 +96,7 @@ fn init_schema(conn: &Connection) -> Result<(), String> {
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))
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 8d9aad5..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 = {
@@ -135,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..06d9cff 100644
--- a/src/components/ChallengeReviewPanel.css
+++ b/src/components/ChallengeReviewPanel.css
@@ -276,3 +276,96 @@
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);
+}
diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx
index b577225..3df0306 100644
--- a/src/components/ChallengeReviewPanel.tsx
+++ b/src/components/ChallengeReviewPanel.tsx
@@ -20,6 +20,7 @@ import { dispatchOpenAiSettings, VAULT_CONFIG_UPDATED_EVENT } from "../utils/vau
import { useAiConfigStatus } from "../hooks/useAiConfigStatus";
import AiNotConfiguredGuide from "./AiNotConfiguredGuide";
import { AiAssistantMarkdown } from "./AiAssistantMarkdown";
+import { ChallengeFeedbackBar } from "./ChallengeFeedbackBar";
import "./ChallengeReviewPanel.css";
type Props = {
@@ -40,6 +41,7 @@ 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);
@@ -105,6 +107,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
uiLocale: getAppLocale(),
},
});
+ setTemplateKind(g.templateKind || undefined);
if (g.shouldSkip || !g.question.trim()) {
setQuestion(t("challengeReview.fallbackQuestion"));
} else {
@@ -442,6 +445,11 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
>
) : null}
+
diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx
index 2209136..4b1b034 100644
--- a/src/components/ChallengeReviewPanel.tsx
+++ b/src/components/ChallengeReviewPanel.tsx
@@ -107,6 +107,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
relPath: currentItem.relPath,
depthMode,
uiLocale: getAppLocale(),
+ ...(!isCandidate && currentItem.thoughtId ? { thoughtId: currentItem.thoughtId } : {}),
...(isCandidate && currentItem.markingReason ? { markingReason: currentItem.markingReason } : {}),
...(isCandidate && currentItem.pairedExcerpt ? { pairedExcerpt: currentItem.pairedExcerpt } : {}),
},
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..7187ffb
--- /dev/null
+++ b/src/components/ThoughtGrowthStoryCard.tsx
@@ -0,0 +1,191 @@
+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 handleExportMarkdown = useCallback(() => {
+ if (!story) 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]);
+
+ const handleExportImage = useCallback(async () => {
+ if (!story || !isTauri()) 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]);
+
+ 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", " 次挑战")}
+
+
+
+
+
+
+
+ {t("growthStory.exportMarkdown", "导出 Markdown")}
+
+
+ {t("growthStory.exportImage", "导出图片")}
+
+
+
+ )}
+
+
+ );
+}
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..76b8b55 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";
@@ -193,6 +194,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 +733,16 @@ export function ThoughtManagementPanel({
{detail.temporary ? t("thoughtPanel.temporary") : t("thoughtManagement.flagNormal")}
+
+ ·
+
+ setGrowthStoryOpen(true)}
+ title={t("growthStory.viewGrowthStory", "查看成长故事")}
+ >
+ {t("growthStory.viewGrowthStory", "成长故事")}
+
@@ -780,6 +792,13 @@ export function ThoughtManagementPanel({
+ {detail && (
+ setGrowthStoryOpen(false)}
+ />
+ )}
);
}
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/locales/en.json b/src/locales/en.json
index 37056fa..5c4d161 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -569,6 +569,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",
@@ -837,5 +844,14 @@
"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"
+ },
+ "growthStory": {
+ "title": "Growth Story",
+ "loading": "Loading…",
+ "viewGrowthStory": "Growth Story",
+ "exportMarkdown": "Export Markdown",
+ "exportImage": "Export Image",
+ "days": " days",
+ "challenges": " challenges"
}
}
diff --git a/src/locales/zh.json b/src/locales/zh.json
index abc0ec9..399cf16 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -569,6 +569,13 @@
"challengeReviewDailyCapIndependent": "独立回顾每日上限(成功计次)",
"challengeReviewDailyCapInline": "对话内联回顾每日上限",
"challengeReviewDailyCapHint": "每条通道允许 1–20;保存时由工作区配置自动钳制。",
+ "cognitivePushSection": "认知回顾推送",
+ "cognitivePushEnable": "启用桌面通知推送",
+ "cognitivePushFrequency": "推送频率",
+ "cognitivePushWeekly": "每周",
+ "cognitivePushMonthly": "每月",
+ "cognitivePushBoth": "每周 + 每月",
+ "cognitivePushHint": "应用运行时自动检查并推送认知回顾通知。无复习活动时不推送周报。",
"errChallengeReviewCaps": "回顾每日上限必须为 1–20 的整数。",
"saving": "正在保存…",
"save": "保存",
@@ -837,5 +844,14 @@
"descConversation": "配置 AI 模型后,可与 AI 对话、分析笔记、获取深度洞见",
"descChallengeReview": "配置 AI 模型后,系统将根据你的笔记自动生成挑战问题",
"descSkill": "配置 AI 模型后,可使用和管理 Skill 扩展能力"
+ },
+ "growthStory": {
+ "title": "成长故事",
+ "loading": "加载中…",
+ "viewGrowthStory": "成长故事",
+ "exportMarkdown": "导出 Markdown",
+ "exportImage": "导出图片",
+ "days": " 天",
+ "challenges": " 次挑战"
}
}
diff --git a/src/types/cognitiveTypes.ts b/src/types/cognitiveTypes.ts
index 830bb75..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;
};
// --- 理解区块(解析结果) ---
@@ -235,6 +244,7 @@ export type GenerateChallengeQuestionArgs = {
uiLocale?: "en" | "zh";
markingReason?: string;
pairedExcerpt?: string;
+ thoughtId?: string;
};
/** `evaluate_challenge_answer` 请求 */
@@ -316,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;
+};
From 50dddfd07b55c0e77326af4980fc87fdf07560fa Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Mon, 13 Jul 2026 15:21:59 +0800
Subject: [PATCH 10/19] feat(ui): complete spec-1c AI degraded guidance and
spec-3b export enhancements
Add AI-not-configured guide to writing coach and thought AI panel;
add image export, privacy checks and confirm dialog for growth story.
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/Cargo.lock | 1 +
src-tauri/Cargo.toml | 1 +
src-tauri/src/growth_story.rs | 148 ++++++++++++++++++
src-tauri/src/lib.rs | 119 ++++++++++++++
src/App.tsx | 1 +
src/components/EditorWritingCoachHost.tsx | 30 +++-
src/components/ThoughtGrowthStoryCard.tsx | 22 ++-
src/components/ThoughtManagementPanel.tsx | 27 +++-
.../ThoughtMgmtAiConversationPanel.tsx | 18 +++
.../cognitive-report/CognitiveReportPanel.css | 16 ++
.../cognitive-report/CognitiveReportPanel.tsx | 13 +-
.../cognitive-report/ThoughtTimeline.tsx | 16 +-
src/locales/en.json | 8 +-
src/locales/zh.json | 8 +-
14 files changed, 408 insertions(+), 20 deletions(-)
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index a8ae082..974066a 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -2871,6 +2871,7 @@ version = "0.7.5"
dependencies = [
"aho-corasick",
"async-trait",
+ "base64 0.22.1",
"bytes",
"candle-core",
"candle-nn",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 220a6b2..3d3f34f 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -50,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/src/growth_story.rs b/src-tauri/src/growth_story.rs
index b8552fb..c7f2420 100644
--- a/src-tauri/src/growth_story.rs
+++ b/src-tauri/src/growth_story.rs
@@ -236,6 +236,154 @@ fn compute_total_days(created_at: &str) -> usize {
}
}
+/// 生成 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#"
+
+
+
+
+
+
+
+
+
+ {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 格式的成长故事
pub fn to_markdown(story: &GrowthStory) -> String {
let maturity_emoji = match story.current_maturity.as_str() {
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 800761e..c1eeb48 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1601,6 +1601,124 @@ async fn get_thought_growth_story(
.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 {
+ use tauri::Manager;
+ 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,
@@ -1943,6 +2061,7 @@ pub fn run() {
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,
diff --git a/src/App.tsx b/src/App.tsx
index d5c7a85..41bb05c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1348,6 +1348,7 @@ function App() {
setLeftPanelView("files");
void onOpenCoachMarkdownPath(relPath);
}}
+ isPathKfPrivate={isPathKfPrivate}
/>
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 ? (
+
+
+
+ setShowAiGuide(false)}>
+ {t("main.writingCoachCollapse")}
+
+
+
+ ) : null}
{showTriggerBtn ? (
{
+ 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);
@@ -128,10 +142,12 @@ export function ThoughtGrowthStoryCard({ thoughtId, open, onClose }: Props) {
a.download = `growth-story-${story.thoughtId}.md`;
a.click();
URL.revokeObjectURL(url);
- }, [story]);
+ }, [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);
@@ -142,7 +158,7 @@ export function ThoughtGrowthStoryCard({ thoughtId, open, onClose }: Props) {
} catch (e) {
console.error("Export image failed:", e);
}
- }, [story]);
+ }, [story, confirmExport]);
if (!open) return null;
diff --git a/src/components/ThoughtManagementPanel.tsx b/src/components/ThoughtManagementPanel.tsx
index 76b8b55..1ada4cf 100644
--- a/src/components/ThoughtManagementPanel.tsx
+++ b/src/components/ThoughtManagementPanel.tsx
@@ -157,6 +157,8 @@ type Props = {
onOpenNote: (relPath: string) => void;
/** 正文相对已加载详情是否未保存,供顶栏退出等全局逻辑使用 */
onThoughtDetailDirtyChange?: (dirty: boolean) => void;
+ /** 判断给定路径是否为 kf-private */
+ isPathKfPrivate?: (relPath: string) => boolean;
};
type DeleteThoughtResponse = {
@@ -179,6 +181,7 @@ export function ThoughtManagementPanel({
tauriRuntime,
onOpenNote,
onThoughtDetailDirtyChange,
+ isPathKfPrivate,
}: Props) {
const { t } = useTranslation();
const [q, setQ] = useState("");
@@ -736,13 +739,23 @@ export function ThoughtManagementPanel({
·
- setGrowthStoryOpen(true)}
- title={t("growthStory.viewGrowthStory", "查看成长故事")}
- >
- {t("growthStory.viewGrowthStory", "成长故事")}
-
+ {(() => {
+ // Check if thought is private
+ const isPrivate = isPathKfPrivate
+ && !detail.standalone
+ && detail.noteRelPath
+ && isPathKfPrivate(detail.noteRelPath);
+ if (isPrivate) return null;
+ return (
+ setGrowthStoryOpen(true)}
+ title={t("growthStory.viewGrowthStory", "查看成长故事")}
+ >
+ {t("growthStory.viewGrowthStory", "成长故事")}
+
+ );
+ })()}
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 (
(null);
const load = useCallback(async () => {
if (!isTauri()) {
@@ -66,6 +68,10 @@ export function CognitiveReportPanel({ open, onClose }: Props) {
} catch { /* ignore */ }
}, []);
+ const handleExportGrowthStory = useCallback((thoughtId: string) => {
+ setGrowthStoryThoughtId(thoughtId);
+ }, []);
+
if (!open) return null;
return (
@@ -95,7 +101,7 @@ export function CognitiveReportPanel({ open, onClose }: Props) {
-
+
>
) : null}
@@ -116,6 +122,11 @@ export function CognitiveReportPanel({ open, onClose }: Props) {
)}
+ setGrowthStoryThoughtId(null)}
+ />
);
}
diff --git a/src/components/cognitive-report/ThoughtTimeline.tsx b/src/components/cognitive-report/ThoughtTimeline.tsx
index d599984..882c7bf 100644
--- a/src/components/cognitive-report/ThoughtTimeline.tsx
+++ b/src/components/cognitive-report/ThoughtTimeline.tsx
@@ -1,9 +1,12 @@
import { useTranslation } from "react-i18next";
import type { CognitiveReportForUi } from "../../types/motivationFeedback";
-type Props = { timelines: CognitiveReportForUi["timelines"] };
+type Props = {
+ timelines: CognitiveReportForUi["timelines"];
+ onExportGrowthStory?: (thoughtId: string) => void;
+};
-export function ThoughtTimeline({ timelines }: Props) {
+export function ThoughtTimeline({ timelines, onExportGrowthStory }: Props) {
const { t } = useTranslation();
if (timelines.length === 0) {
@@ -39,6 +42,15 @@ export function ThoughtTimeline({ timelines }: Props) {
))}
+ {onExportGrowthStory && (
+ onExportGrowthStory(tl.thoughtId)}
+ title={t("growthStory.viewGrowthStory", "成长故事")}
+ >
+ {t("growthStory.viewGrowthStory", "成长故事")}
+
+ )}
))}
diff --git a/src/locales/en.json b/src/locales/en.json
index 5c4d161..4d62c1b 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -843,7 +843,9 @@
"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"
+ "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",
@@ -852,6 +854,8 @@
"exportMarkdown": "Export Markdown",
"exportImage": "Export Image",
"days": " days",
- "challenges": " challenges"
+ "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 399cf16..53e34ea 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -843,7 +843,9 @@
"configure": "配置 AI 模型",
"descConversation": "配置 AI 模型后,可与 AI 对话、分析笔记、获取深度洞见",
"descChallengeReview": "配置 AI 模型后,系统将根据你的笔记自动生成挑战问题",
- "descSkill": "配置 AI 模型后,可使用和管理 Skill 扩展能力"
+ "descSkill": "配置 AI 模型后,可使用和管理 Skill 扩展能力",
+ "descWritingCoach": "配置 AI 模型后,写作教练将在你书写时提供深度思考引导",
+ "descThoughtAi": "配置 AI 模型后,可与 AI 就想法进行深入对话和分析"
},
"growthStory": {
"title": "成长故事",
@@ -852,6 +854,8 @@
"exportMarkdown": "导出 Markdown",
"exportImage": "导出图片",
"days": " 天",
- "challenges": " 次挑战"
+ "challenges": " 次挑战",
+ "confirmExport": "导出的内容可能包含你笔记中的部分文字,确认分享?",
+ "confirmExportTitle": "确认导出"
}
}
From 4e6d16de6ec703d4bd2cfa8c9a0adc96c38aea7c Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Mon, 13 Jul 2026 17:30:59 +0800
Subject: [PATCH 11/19] fix(latent): trigger scan on workspace open when
candidates table is empty
Previously scan_vault only ran after rebuild_index with indexed_chunks > 0.
If the embedding index was already built, subsequent app launches never
triggered a scan, leaving the review queue permanently empty of latent
paragraph candidates.
Now open_workspace checks for the "has chunks but no candidates" condition
and fires a background scan to populate the candidates table.
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/lib.rs | 34 ++++++++++++++++++++++++++++++++++
1 file changed, 34 insertions(+)
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index c1eeb48..fe77c8c 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -416,6 +416,40 @@ async fn open_workspace(
cleanup_expired_tool_results(&cleanup_root).await;
});
+ // Latent paragraph scan: if embedding index exists but no active candidates,
+ // run a scan so the review queue can show latent paragraph challenges.
+ {
+ 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;
+ 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_chunks && !has_candidates {
+ use tauri::Manager;
+ eprintln!("[open_workspace] embedding index exists but no latent candidates, triggering scan");
+ 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();
From 7f428cab39456360f1959269451d4b864fc0fcc6 Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Mon, 13 Jul 2026 17:55:42 +0800
Subject: [PATCH 12/19] feat(review): improve latent paragraph UX and add
content filters
Review panel:
- Fix React key collision for candidate items (candidateId || thoughtId)
- Add loading state text on "Start challenge" button while generating
- Add "Skip" button that dismisses current candidate and reloads queue
- Strip .md extension from candidate file display name
- Show marking reason (similar/isolated/cross-doc) below candidate label
Content filters (latent_paragraphs.rs):
- Add table filter: skip chunks where >50% lines contain pipe chars
- Add frontmatter filter: skip YAML metadata blocks (--- fenced)
- Add heading-only filter: skip chunks with only # heading lines
- Add 6 unit tests for the new filters
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/latent_paragraphs.rs | 83 +++++++++++++++++++++++++
src/components/ChallengeReviewPanel.tsx | 57 +++++++++++++++--
src/locales/en.json | 7 ++-
src/locales/zh.json | 7 ++-
4 files changed, 148 insertions(+), 6 deletions(-)
diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs
index afd1777..b9497bc 100644
--- a/src-tauri/src/latent_paragraphs.rs
+++ b/src-tauri/src/latent_paragraphs.rs
@@ -92,6 +92,15 @@ pub fn should_skip_chunk(text: &str) -> bool {
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
}
@@ -166,6 +175,44 @@ fn is_quote_block(text: &str) -> bool {
lines.iter().all(|line| line.trim_start().starts_with("> "))
}
+/// Markdown table: lines containing `|` pipes or separator rows like `|---|`.
+/// Skip if > 50% of non-empty lines look like table rows.
+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;
+ }
+ let table_lines = lines
+ .iter()
+ .filter(|l| {
+ let t = l.trim();
+ // Table row: contains at least one `|` that isn't at the very start of a blockquote
+ t.contains('|')
+ })
+ .count();
+ table_lines * 100 / lines.len() > 50
+}
+
+/// 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
// ---------------------------------------------------------------------------
@@ -968,4 +1015,40 @@ mod tests {
"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() {
+ 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.";
+ 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/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx
index 4b1b034..1476ace 100644
--- a/src/components/ChallengeReviewPanel.tsx
+++ b/src/components/ChallengeReviewPanel.tsx
@@ -29,6 +29,12 @@ 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, "");
+}
+
export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
const { t, i18n } = useTranslation();
const { openMarkdownTab } = useAiNoteContext();
@@ -344,7 +350,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
{t("challengeReview.panelBatchListTitle", { count: items.length })}
{items.map((it, i) => (
- -
+
-
{i + 1}
{it.sourceType === "candidate" ? (
- {t("challengeReview.candidateLabel", { file: it.relPath.split("/").pop() ?? it.relPath })}
+ {t("challengeReview.candidateLabel", { file: displayName(it.relPath) })}
) : (
@@ -393,7 +399,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
<>
{currentItem.sourceType === "candidate"
- ? t("challengeReview.candidateLabel", { file: currentItem.relPath.split("/").pop() ?? currentItem.relPath })
+ ? t("challengeReview.candidateLabel", { file: displayName(currentItem.relPath) })
: currentItem.relPath}
{currentItem.sourceType !== "candidate" ? (
@@ -405,6 +411,16 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
{t("challengeReview.createdLabel", { time: createdDisplay(currentItem.created) })}
+ ) : currentItem.markingReason ? (
+
+ {currentItem.markingReason === "high_similarity"
+ ? t("challengeReview.reasonHighSimilarity")
+ : currentItem.markingReason === "semantic_isolated"
+ ? t("challengeReview.reasonSemanticIsolated")
+ : currentItem.markingReason === "cross_doc_recurrence"
+ ? t("challengeReview.reasonCrossDocRecurrence")
+ : currentItem.markingReason}
+
) : null}
{currentItem.excerpt && !currentItem.privateOmitted ? (
{currentItem.excerpt}
@@ -416,8 +432,41 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
disabled={busy}
onClick={() => void startRound()}
>
- {t("challengeReview.startRound")}
+ {busy ? t("challengeReview.generating") : t("challengeReview.startRound")}
+ {currentItem.sourceType === "candidate" && currentItem.candidateId ? (
+ {
+ setBusy(true);
+ invoke("dismiss_latent_candidate", { candidateId: currentItem.candidateId })
+ .catch(() => {})
+ .finally(() => {
+ void reloadQueue().then((q) => {
+ if (!q || q.items.length === 0) {
+ setCursor(0);
+ } else {
+ setCursor((c) => Math.min(c, q.items.length - 1));
+ }
+ setBusy(false);
+ });
+ });
+ }}
+ >
+ {t("challengeReview.skipItem")}
+
+ ) : items.length > 1 ? (
+ setCursor((c) => (c + 1) % items.length)}
+ >
+ {t("challengeReview.skipItem")}
+
+ ) : null}
{openMarkdownTab ? (
Date: Mon, 13 Jul 2026 17:58:27 +0800
Subject: [PATCH 13/19] chore: suppress 5 compiler warnings (unused imports,
variables, dead code)
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/growth_story.rs | 3 ++-
src-tauri/src/latent_paragraphs.rs | 1 +
src-tauri/src/lib.rs | 5 +++--
src-tauri/src/vault_thoughts_db.rs | 1 +
4 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/src-tauri/src/growth_story.rs b/src-tauri/src/growth_story.rs
index c7f2420..09868dd 100644
--- a/src-tauri/src/growth_story.rs
+++ b/src-tauri/src/growth_story.rs
@@ -384,7 +384,8 @@ pub fn to_html_card(story: &GrowthStory) -> String {
)
}
-/// 生成 Markdown 格式的成长故事
+/// 生成 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" => "🌱",
diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs
index b9497bc..e177a96 100644
--- a/src-tauri/src/latent_paragraphs.rs
+++ b/src-tauri/src/latent_paragraphs.rs
@@ -28,6 +28,7 @@ pub struct CandidateForUi {
}
#[derive(Debug, Clone)]
+#[allow(dead_code)]
pub struct ScanResult {
pub total_chunks_scanned: usize,
pub candidates_found: usize,
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index fe77c8c..c0c418c 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1638,11 +1638,12 @@ async fn get_thought_growth_story(
#[tauri::command]
async fn export_growth_story_as_image(
thought_id: String,
- markdown: String,
+ _markdown: String,
state: tauri::State<'_, WorkspaceState>,
app: tauri::AppHandle,
) -> Result {
- use tauri::Manager;
+ #[allow(unused_imports)]
+ use tauri::Manager; // required for WebviewWindowBuilder::new(&app, ...)
use tauri::Listener;
let root = lock_workspace_root(&state)?;
diff --git a/src-tauri/src/vault_thoughts_db.rs b/src-tauri/src/vault_thoughts_db.rs
index 46ba641..79501c1 100644
--- a/src-tauri/src/vault_thoughts_db.rs
+++ b/src-tauri/src/vault_thoughts_db.rs
@@ -282,6 +282,7 @@ pub fn graph_thought_stats(conn: &Connection) -> Result
}
/// 回顾排期:侧车行 + 元数据列(YAML 不再扫 callout);不含独立想法
+#[allow(dead_code)]
pub struct ThoughtRowForReview {
pub rel_path: String,
pub thought_id: String,
From adeb284a333abb41b77b117b2f6c001ccd21c7c3 Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Mon, 13 Jul 2026 18:04:17 +0800
Subject: [PATCH 14/19] feat(review): show related documents for latent
paragraph candidates
For cross_doc_recurrence candidates, store the other document paths in
the cluster (excluding self) so the UI can display which notes contain
similar concepts. For high_similarity candidates, the paired document
was already available.
Frontend shows a "Related notes:" section with document name tags
below the marking reason when viewing a candidate in the review panel.
Co-authored-by: Claude (Opus 4.6)
---
src-tauri/src/latent_paragraphs.rs | 10 ++++++++-
src/components/ChallengeReviewPanel.css | 19 ++++++++++++++++
src/components/ChallengeReviewPanel.tsx | 30 +++++++++++++++++--------
src/locales/en.json | 1 +
src/locales/zh.json | 1 +
5 files changed, 51 insertions(+), 10 deletions(-)
diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs
index e177a96..8bc55d4 100644
--- a/src-tauri/src/latent_paragraphs.rs
+++ b/src-tauri/src/latent_paragraphs.rs
@@ -417,11 +417,19 @@ fn compute_candidates(chunks: &[&DocChunkRow]) -> Vec {
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: None,
+ paired_rel_path: paired,
});
}
}
diff --git a/src/components/ChallengeReviewPanel.css b/src/components/ChallengeReviewPanel.css
index 045b3d3..a6a0333 100644
--- a/src/components/ChallengeReviewPanel.css
+++ b/src/components/ChallengeReviewPanel.css
@@ -411,3 +411,22 @@
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 1476ace..d35bbae 100644
--- a/src/components/ChallengeReviewPanel.tsx
+++ b/src/components/ChallengeReviewPanel.tsx
@@ -412,15 +412,27 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
{t("challengeReview.createdLabel", { time: createdDisplay(currentItem.created) })}
) : currentItem.markingReason ? (
-
- {currentItem.markingReason === "high_similarity"
- ? t("challengeReview.reasonHighSimilarity")
- : currentItem.markingReason === "semantic_isolated"
- ? t("challengeReview.reasonSemanticIsolated")
- : currentItem.markingReason === "cross_doc_recurrence"
- ? t("challengeReview.reasonCrossDocRecurrence")
- : currentItem.markingReason}
-
+ <>
+
+ {currentItem.markingReason === "high_similarity"
+ ? t("challengeReview.reasonHighSimilarity")
+ : currentItem.markingReason === "semantic_isolated"
+ ? t("challengeReview.reasonSemanticIsolated")
+ : currentItem.markingReason === "cross_doc_recurrence"
+ ? t("challengeReview.reasonCrossDocRecurrence")
+ : currentItem.markingReason}
+
+ {currentItem.pairedExcerpt ? (
+
+ {t("challengeReview.relatedDocs")}
+ {currentItem.pairedExcerpt.split(",").map((p) => (
+
+ {displayName(p.trim())}
+
+ ))}
+
+ ) : null}
+ >
) : null}
{currentItem.excerpt && !currentItem.privateOmitted ? (
{currentItem.excerpt}
diff --git a/src/locales/en.json b/src/locales/en.json
index c9af324..2c1ce2c 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -446,6 +446,7 @@
"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",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 125219c..62944ba 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -446,6 +446,7 @@
"reasonHighSimilarity": "与其他笔记存在近似表述,值得辨析",
"reasonSemanticIsolated": "与其他笔记缺少关联,值得深入",
"reasonCrossDocRecurrence": "多篇笔记涉及相近概念,值得梳理",
+ "relatedDocs": "相关笔记:",
"promotePrompt": "这个想法值得长期追踪吗?",
"promoteTrack": "开始追踪",
"promoteDismiss": "不了",
From 160a40fd5e7755857a5401be66862e61ede29879 Mon Sep 17 00:00:00 2001
From: donfaquir <1458918806@qq.com>
Date: Mon, 13 Jul 2026 18:09:02 +0800
Subject: [PATCH 15/19] perf(review): pre-generate challenge questions to
eliminate wait time
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a prefetch pipeline that generates questions in the background:
- On queue load, immediately start generating for items[0], chain to [1]
- startRound checks cache first — cache hit = instant question (0 wait)
- Cache miss falls back to on-demand generation (existing behavior)
- After answering or skipping, prefetch the next item in the queue
- Cache is cleared on queue reload (dismiss/promote) to stay fresh
Typical user experience after this change:
- First item: 0-5s wait (prefetch races with user reading the excerpt)
- Second item onward: 0 wait (question pre-generated while answering)
Co-authored-by: Claude (Opus 4.6)
---
src/components/ChallengeReviewPanel.tsx | 133 +++++++++++++++++++-----
1 file changed, 105 insertions(+), 28 deletions(-)
diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx
index d35bbae..92540b6 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";
@@ -35,6 +35,11 @@ function displayName(relPath: string): string {
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();
@@ -52,6 +57,10 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) {
/** 当日独立回顾成功次数已达 cap */
const [independentCapBlocked, setIndependentCapBlocked] = useState(false);
+ // --- Pre-generation pipeline ---
+ const questionCacheRef = useRef