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 ( React.JSX.Element> = { files: FilesIcon, - graph: GraphIcon, thoughts: ThoughtsIcon, }; const VIEW_I18N_KEYS: Record = { files: "activityBar.files", - graph: "activityBar.graph", thoughts: "activityBar.thoughts", }; diff --git a/src/components/AiLlmSettingsModal.tsx b/src/components/AiLlmSettingsModal.tsx index c5d9e1c..d4a6a9a 100644 --- a/src/components/AiLlmSettingsModal.tsx +++ b/src/components/AiLlmSettingsModal.tsx @@ -1,7 +1,7 @@ import { getVersion } from "@tauri-apps/api/app"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { ask } from "@tauri-apps/plugin-dialog"; -import { useCallback, useEffect, useMemo, useRef, useState, lazy, Suspense } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import packageMeta from "../../package.json"; import i18n, { setAppLocale } from "../i18n"; @@ -10,7 +10,8 @@ import { dispatchVaultConfigUpdated } from "../utils/vaultConfigBroadcast"; import { SemanticIndexStatus } from "./SemanticIndexStatus"; import "./AiLlmSettingsModal.css"; -const SkillManagementPanel = lazy(() => import("./SkillManagementPanel")); +// Frozen: Skill management panel hidden from UI (code preserved) +// const SkillManagementPanel = lazy(() => import("./SkillManagementPanel")); /** 与 Tauri 可拖拽窗口配合:排除交互区(非桌面端传空对象) */ export type TauriDragRegionExcludeProps = @@ -25,7 +26,7 @@ export type AiLlmSettingsModalProps = { dragExcludeProps: TauriDragRegionExcludeProps; }; -type SettingsSection = "general" | "ai" | "skills"; +type SettingsSection = "general" | "ai"; /** 左侧「通用」分区:滑块调谐图标 */ function IconGeneralSettings() { @@ -98,24 +99,6 @@ function IconAiLlmSection() { ); } -/** 左侧「技能」分区:扳手/工具图标 */ -function IconSkillsSection() { - return ( - - - - ); -} // --- Provider form state --- @@ -921,17 +904,7 @@ export function AiLlmSettingsModal({ {t("settings.aiLlm")} - + {/* Frozen: skills nav button hidden */} {/* 外层固定高度由 .app-modal--settings 控制;此处唯一滚动区适配 General/AI */} @@ -1654,18 +1627,7 @@ export function AiLlmSettingsModal({
- ) : ( - {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} -
-
- - {!disabled ? ( - - ) : 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} +
+ +
+ + {!disabled && ( + + )} +
+
+
+ ); +} 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} +
+
+ ); + } + + if (outcome === "dismissed") { + return ( +
+

{t("challengeReview.promoteDismissed")}

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

{t("challengeReview.promotePrompt")}

+
+ + + +
+
+ ); +} diff --git a/src/components/ChallengeReviewPanel.css b/src/components/ChallengeReviewPanel.css index 06d9cff..045b3d3 100644 --- a/src/components/ChallengeReviewPanel.css +++ b/src/components/ChallengeReviewPanel.css @@ -369,3 +369,45 @@ background: color-mix(in srgb, var(--kf-text, #fff) 8%, transparent); border-color: var(--kf-border, #444); } + +/* --- CandidatePromoteCard --- */ + +.candidate-promote-card { + margin-top: 12px; + padding: 12px; + border-radius: 8px; + background: color-mix(in srgb, #8b5cf6, transparent 92%); + border: 1px solid color-mix(in srgb, #8b5cf6, transparent 78%); +} + +.candidate-promote-card--done { + background: color-mix(in srgb, #10b981, transparent 92%); + border-color: color-mix(in srgb, #10b981, transparent 78%); +} + +.candidate-promote-card__prompt { + margin: 0 0 8px; + font-size: 0.85rem; + font-weight: 500; +} + +.candidate-promote-card__msg { + margin: 0 0 6px; + font-size: 0.85rem; +} + +.candidate-promote-card__actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.challenge-review-panel__queue-row .challenge-review-panel__candidate-tag { + font-size: 0.68rem; + padding: 1px 5px; + border-radius: 4px; + background: color-mix(in srgb, #8b5cf6, transparent 85%); + color: #7c3aed; + white-space: nowrap; +} diff --git a/src/components/ChallengeReviewPanel.tsx b/src/components/ChallengeReviewPanel.tsx index 3df0306..2209136 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 { CandidatePromoteCard } from "./CandidatePromoteCard"; import { ChallengeFeedbackBar } from "./ChallengeFeedbackBar"; import "./ChallengeReviewPanel.css"; @@ -99,12 +100,15 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { if (!currentItem) return; setBusy(true); try { + const isCandidate = currentItem.sourceType === "candidate"; const g = await invoke("generate_challenge_question", { args: { thoughtExcerpt: currentItem.excerpt || currentItem.created, relPath: currentItem.relPath, depthMode, uiLocale: getAppLocale(), + ...(isCandidate && currentItem.markingReason ? { markingReason: currentItem.markingReason } : {}), + ...(isCandidate && currentItem.pairedExcerpt ? { pairedExcerpt: currentItem.pairedExcerpt } : {}), }, }); setTemplateKind(g.templateKind || undefined); @@ -136,24 +140,28 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { }); setEvalRes(ev); setPhase("result"); + const isCandidate = currentItem.sourceType === "candidate"; void trackKnowforgeEvent("review.panel_evaluated", { thoughtId: currentItem.thoughtId, passed: ev.passed, sloppy: ev.sloppy, + sourceType: currentItem.sourceType, }); - 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 freqCtrl.recordChallengeIndependentShown(currentItem.thoughtId); - await freqCtrl.reload(); - if (!freqCtrl.canStartMoreIndependentReviewsToday()) { - setIndependentCapBlocked(true); + if (!isCandidate) { + 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 freqCtrl.recordChallengeIndependentShown(currentItem.thoughtId); + await freqCtrl.reload(); + if (!freqCtrl.canStartMoreIndependentReviewsToday()) { + setIndependentCapBlocked(true); + } } } } catch { @@ -348,12 +356,20 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { >
{i + 1} - - {it.relPath} - - - {t("challengeReview.dueLabel", { days: it.overdueDays })} - + {it.sourceType === "candidate" ? ( + + {t("challengeReview.candidateLabel", { file: it.relPath.split("/").pop() ?? it.relPath })} + + ) : ( + + {it.relPath} + + )} + {it.sourceType !== "candidate" ? ( + + {t("challengeReview.dueLabel", { days: it.overdueDays })} + + ) : null}
{it.privateOmitted ? (
@@ -375,14 +391,20 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { {phase === "pick" ? ( <>
- {currentItem.relPath} - - {t("challengeReview.dueLabel", { days: currentItem.overdueDays })} - -
-
- {t("challengeReview.createdLabel", { time: createdDisplay(currentItem.created) })} + {currentItem.sourceType === "candidate" + ? t("challengeReview.candidateLabel", { file: currentItem.relPath.split("/").pop() ?? currentItem.relPath }) + : currentItem.relPath} + {currentItem.sourceType !== "candidate" ? ( + + {t("challengeReview.dueLabel", { days: currentItem.overdueDays })} + + ) : null}
+ {currentItem.sourceType !== "candidate" ? ( +
+ {t("challengeReview.createdLabel", { time: createdDisplay(currentItem.created) })} +
+ ) : null} {currentItem.excerpt && !currentItem.privateOmitted ? (
{currentItem.excerpt}
) : null} @@ -436,7 +458,7 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { {phase === "result" && evalRes ? (
{evalRes.sloppy ?

{t("challengeReview.sloppyHint")}

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

{t("challengeReview.passed")}

{!evalRes.sloppy ? ( @@ -445,19 +467,28 @@ export function ChallengeReviewPanel({ onClose, depthMode }: Props) { ) : null} - -
- - -
+ {currentItem?.sourceType === "candidate" && currentItem.candidateId ? ( + void goNext().catch(() => {})} + /> + ) : ( + <> + +
+ + +
+ + )}
) : null}
diff --git a/src/locales/en.json b/src/locales/en.json index d329289..37056fa 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -385,7 +385,7 @@ "generating": "Generating deepening question...", "defaultQuestion": "Want me to explore this topic further?", "acceptedUserMessageDefault": "I'd like to explore another angle.", - "thoughtQuestion": "{{excerpt}} \u2014 Want me to dig deeper from here?", + "thoughtQuestion": "{{excerpt}} — Want me to dig deeper from here?", "snoozeConfirm": "Snooze deepening invitations. How long?", "snoozeBtn": "Not now", "snoozeDays_one": "{{count}} day", @@ -439,7 +439,14 @@ "reason_too_easy": "Too easy", "reason_irrelevant": "Irrelevant to content", "reason_too_vague": "Too vague", - "reason_duplicate": "Repeated question" + "reason_duplicate": "Repeated question", + "candidateLabel": "An idea from {{file}}", + "promotePrompt": "Is this idea worth tracking long-term?", + "promoteTrack": "Start tracking", + "promoteDismiss": "No thanks", + "promoteSkip": "Ask me later", + "promoteSuccess": "Added as a formal thought", + "promoteDismissed": "Skipped, won't recommend again" }, "thoughtSave": { "buttonTitle": "Save as thought", diff --git a/src/locales/zh.json b/src/locales/zh.json index 056e81a..abc0ec9 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -385,7 +385,7 @@ "generating": "正在生成深化问题…", "defaultQuestion": "需要我从其他角度继续分析吗?", "acceptedUserMessageDefault": "换个角度继续聊聊。", - "thoughtQuestion": "{{excerpt}} \u2014 要不要从这里继续探讨?", + "thoughtQuestion": "{{excerpt}} — 要不要从这里继续探讨?", "snoozeConfirm": "暂停邀请后,新对话中将不再展示深化邀请。暂停多久?", "snoozeBtn": "最近不需要", "snoozeDays_one": "{{count}} 天", @@ -439,7 +439,14 @@ "reason_too_easy": "太简单了", "reason_irrelevant": "和内容不相关", "reason_too_vague": "问得太模糊", - "reason_duplicate": "和之前重复了" + "reason_duplicate": "和之前重复了", + "candidateLabel": "来自 {{file}} 的一段想法", + "promotePrompt": "这个想法值得长期追踪吗?", + "promoteTrack": "开始追踪", + "promoteDismiss": "不了", + "promoteSkip": "下次再问", + "promoteSuccess": "已添加为正式理解", + "promoteDismissed": "已跳过,不再推荐" }, "thoughtSave": { "buttonTitle": "保存为想法", diff --git a/src/types/cognitiveTypes.ts b/src/types/cognitiveTypes.ts index 64f43d7..830bb75 100644 --- a/src/types/cognitiveTypes.ts +++ b/src/types/cognitiveTypes.ts @@ -233,6 +233,8 @@ export type GenerateChallengeQuestionArgs = { depthMode?: DepthMode; /** 与 Knowforge 设置一致:`en` | `zh`,驱动模型输出自然语言 */ uiLocale?: "en" | "zh"; + markingReason?: string; + pairedExcerpt?: string; }; /** `evaluate_challenge_answer` 请求 */ @@ -301,6 +303,11 @@ export type ReviewQueueItem = { nextDueAt: string; overdueDays: number; privateOmitted: boolean; + sourceType: "thought" | "candidate"; + candidateId?: string; + markingReason?: string; + pairedExcerpt?: string; + startLine?: number; }; export type ListReviewQueueResponse = { From 5e0123fa741c4bed4d930f4f46ce8ca0869bc55e Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Mon, 13 Jul 2026 11:16:00 +0800 Subject: [PATCH 09/19] feat(cognitive): add review push notifications and thought growth story export - Add cognitive push scheduler (startup + 30min periodic) with tauri-plugin-notification - Add configurable push frequency and quiet hours in Settings > Cognition - Add ThoughtGrowthStoryCard component with journey timeline and Markdown export - Add get_thought_growth_story Tauri command for reading thought history - Register growth_story module and cognitive_push in lib.rs invoke_handler Co-authored-by: Claude (big-pickle) --- src-tauri/Cargo.lock | 425 ++++++++++++++++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 3 +- src-tauri/src/challenge_feedback.rs | 57 +++ src-tauri/src/challenge_prompts.rs | 351 ++++++++++++++++++ src-tauri/src/challenge_review.rs | 150 ++------ src-tauri/src/cognitive_push.rs | 287 +++++++++++++++ src-tauri/src/growth_story.rs | 336 +++++++++++++++++ src-tauri/src/lib.rs | 110 ++++++ src-tauri/src/vault_config.rs | 47 +++ src-tauri/src/writing_coach.rs | 3 +- src/components/AiLlmSettingsModal.tsx | 38 ++ src/components/ChallengeReviewPanel.tsx | 1 + src/components/ThoughtGrowthStoryCard.css | 136 +++++++ src/components/ThoughtGrowthStoryCard.tsx | 191 ++++++++++ src/components/ThoughtManagementPanel.css | 14 + src/components/ThoughtManagementPanel.tsx | 19 + src/hooks/useAgentEventHandlers.ts | 1 + src/locales/en.json | 16 + src/locales/zh.json | 16 + src/types/cognitiveTypes.ts | 31 ++ 21 files changed, 2116 insertions(+), 117 deletions(-) create mode 100644 src-tauri/src/challenge_prompts.rs create mode 100644 src-tauri/src/cognitive_push.rs create mode 100644 src-tauri/src/growth_story.rs create mode 100644 src/components/ThoughtGrowthStoryCard.css create mode 100644 src/components/ThoughtGrowthStoryCard.tsx diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ce71a71..a8ae082 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -96,6 +96,126 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -232,6 +352,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -551,6 +684,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -1177,6 +1319,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1189,6 +1337,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1231,6 +1400,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1444,6 +1634,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2693,6 +2896,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-notification", "tempfile", "tokenizers", "tokio", @@ -2916,6 +3120,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -3226,6 +3444,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "notify-types" version = "1.0.1" @@ -3562,6 +3794,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "outref" version = "0.5.2" @@ -3593,6 +3835,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3849,6 +4097,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "piston-float" version = "1.0.1" @@ -3906,6 +4165,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "pom" version = "1.1.0" @@ -4984,6 +5257,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5491,6 +5774,25 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.4", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "time", + "url", +] + [[package]] name = "tauri-runtime" version = "2.11.1" @@ -5593,6 +5895,17 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -6057,6 +6370,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "ug" version = "0.1.0" @@ -7283,6 +7607,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.1", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.1", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -7383,3 +7768,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.1", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 1.0.1", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2906d32..220a6b2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -20,6 +20,7 @@ tauri-build = { version = "2.6", features = [] } # 与前端 @tauri-apps/api 保持同一 minor(Tauri CLI 会校验),勿只写 major=2 导致与 npm 漂移 tauri = { version = "2.11", features = ["macos-private-api"] } tauri-plugin-dialog = "2.7" +tauri-plugin-notification = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" url = "2.5" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index a8c80c0..5f0ed98 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-minimize", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", - "dialog:default" + "dialog:default", + "notification:default" ] } diff --git a/src-tauri/src/challenge_feedback.rs b/src-tauri/src/challenge_feedback.rs index cffa25a..7734e16 100644 --- a/src-tauri/src/challenge_feedback.rs +++ b/src-tauri/src/challenge_feedback.rs @@ -16,6 +16,7 @@ pub fn init_feedback_table(conn: &Connection) -> Result<(), String> { ); CREATE INDEX IF NOT EXISTS idx_cf_rating ON challenge_feedback(rating); CREATE INDEX IF NOT EXISTS idx_cf_template ON challenge_feedback(question_template); + CREATE INDEX IF NOT EXISTS idx_cf_thought_id ON challenge_feedback(thought_id); "#, ) .map_err(|e| format!("init challenge_feedback table: {e}"))?; @@ -167,6 +168,27 @@ pub fn query_feedback_stats(conn: &Connection) -> Result }) } +pub fn query_recent_questions( + conn: &Connection, + thought_id: &str, + limit: usize, +) -> Result, String> { + let cap = limit.min(20); + let mut stmt = conn + .prepare( + "SELECT question_text FROM challenge_feedback + WHERE thought_id = ?1 + ORDER BY created_at DESC + LIMIT ?2", + ) + .map_err(|e| format!("prepare recent questions: {e}"))?; + let rows = stmt + .query_map(params![thought_id, cap as i64], |row| row.get::<_, String>(0)) + .map_err(|e| format!("query recent questions: {e}"))?; + rows.collect::, _>>() + .map_err(|e| format!("read recent questions: {e}")) +} + #[tauri::command] pub async fn submit_challenge_feedback( state: tauri::State<'_, crate::WorkspaceState>, @@ -252,6 +274,41 @@ mod tests { assert!(stats.common_issues.is_empty()); } + #[test] + fn query_recent_questions_returns_latest_n() { + let conn = setup_db(); + for i in 0..7 { + insert_feedback( + &conn, + Some("t1"), + &format!("Question {i}"), + Some("apply"), + "helpful", + None, + ) + .unwrap(); + } + let qs = query_recent_questions(&conn, "t1", 5).unwrap(); + assert_eq!(qs.len(), 5); + assert_eq!(qs[0], "Question 6"); + assert_eq!(qs[4], "Question 2"); + } + + #[test] + fn query_recent_questions_filters_by_thought_id() { + let conn = setup_db(); + insert_feedback(&conn, Some("t1"), "Q for t1", Some("apply"), "helpful", None).unwrap(); + insert_feedback(&conn, Some("t2"), "Q for t2", Some("apply"), "helpful", None).unwrap(); + insert_feedback(&conn, Some("t1"), "Q2 for t1", Some("compare"), "helpful", None).unwrap(); + + let qs = query_recent_questions(&conn, "t1", 10).unwrap(); + assert_eq!(qs.len(), 2); + assert!(qs.iter().all(|q| q.contains("t1"))); + + let empty = query_recent_questions(&conn, "unknown", 10).unwrap(); + assert!(empty.is_empty()); + } + #[test] fn multiple_reasons() { let conn = setup_db(); diff --git a/src-tauri/src/challenge_prompts.rs b/src-tauri/src/challenge_prompts.rs new file mode 100644 index 0000000..14642de --- /dev/null +++ b/src-tauri/src/challenge_prompts.rs @@ -0,0 +1,351 @@ +use crate::challenge_feedback::FeedbackStats; +use crate::vault_config::DepthMode; + +pub const BASE_SYSTEM_PROMPT: &str = r#"You design ONE short challenge question to help the user revisit a saved thought from their notes. + +Pick the best template kind: +- "compare": contrast two ideas or test whether a distinction still holds in a scenario. +- "apply": ask them to apply the thought to a new concrete situation. +- "critique": challenge an implicit assumption politely. +- "transfer": ask whether an idea from domain A could inform domain B. + +Rules: +- The question must be answerable in a few sentences; no multi-part essays. +- If the user message includes a "UI locale" line, write the `question` in that language (English vs Chinese) regardless of excerpt language. +- Otherwise match the thought excerpt language (Chinese excerpt → Chinese question; English → English). +- Respond with ONE JSON object only (no markdown fences, no prose). Keys (camelCase): + - "question": string (non-empty unless skipped) + - "templateKind": one of compare | apply | critique | transfer + - "skipped": boolean — true if the excerpt is too thin or unsafe to challenge; then set question to "". + +Example: {"question":"...","templateKind":"apply","skipped":false}"#; + +pub const FALLBACK_CHALLENGE_QUESTION_ZH: &str = + "你之前写过这个想法,现在还同意这个观点吗?"; + +pub const FALLBACK_CHALLENGE_QUESTION_EN: &str = + "You wrote this idea before — do you still agree with it?"; + +pub(crate) fn ui_locale_is_zh(ui_locale: Option<&str>) -> bool { + matches!( + ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("zh" | "zh-cn" | "zh-hans" | "zh-hant" | "zh-tw") + ) +} + +pub(crate) fn ui_locale_is_en(ui_locale: Option<&str>) -> bool { + matches!( + ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("en") | Some("en-us") | Some("en-gb") + ) +} + +pub(crate) fn generate_ui_locale_paragraph(ui_locale: Option<&str>) -> &'static str { + if ui_locale_is_zh(ui_locale) { + "UI locale: Chinese (Simplified). Write the JSON `question` field in natural Chinese (简体中文), even if the excerpt is in another language." + } else if ui_locale_is_en(ui_locale) { + "UI locale: English. Write the JSON `question` field in English, even if the excerpt is in another language." + } else { + "Language: If no UI locale was specified, match the thought excerpt language for the question." + } +} + +pub(crate) fn depth_tone_line(d: DepthMode) -> &'static str { + match d { + DepthMode::Shallow => "Keep the challenge question very short (one sentence).", + DepthMode::Medium => "Keep the challenge question concise (1-2 sentences).", + DepthMode::Deep => { + "You may use a slightly richer challenge question (still under 3 sentences)." + } + DepthMode::Auto => "Keep the challenge question concise (1-2 sentences).", + } +} + +pub(crate) fn candidate_degraded_question( + reason: &str, + _excerpt: &str, + paired: Option<&str>, + locale: Option<&str>, +) -> String { + let is_en = ui_locale_is_en(locale); + match reason { + "high_similarity" => { + if let Some(p) = paired { + if is_en { + format!("Your notes contain similar content in another file ({p}). What's the unique perspective in this paragraph?") + } else { + format!( + "你的笔记在另一个文件({p})中有类似内容。这段话的独特之处是什么?" + ) + } + } else if is_en { + "Your notes contain similar paragraphs. What's the key difference between them?" + .to_string() + } else { + "你的笔记中有多段相似内容,它们的核心区别是什么?".to_string() + } + } + "semantic_isolated" => { + if is_en { + "This idea seems isolated from your other notes. What connections can you draw to other topics you've written about?".to_string() + } else { + "这个想法和你的其他笔记似乎没有关联。你能找到它与其他主题之间的联系吗?" + .to_string() + } + } + "cross_doc_recurrence" => { + if is_en { + "A similar concept appears across several of your notes. Has your understanding of it evolved over time?".to_string() + } else { + "你在多篇笔记中提到了类似的概念,你对它的理解有变化吗?".to_string() + } + } + _ => { + if is_en { + "What's the core insight in this paragraph, and do you still agree with it?" + .to_string() + } else { + "这段话的核心观点是什么?你现在还同意吗?".to_string() + } + } + } +} + +pub(crate) fn normalize_template_kind(raw: Option<&str>) -> String { + let s = raw.unwrap_or("apply").trim().to_ascii_lowercase(); + match s.as_str() { + "compare" | "comparison" => "compare".to_string(), + "critique" | "critical" => "critique".to_string(), + "transfer" | "migration" => "transfer".to_string(), + "apply" | "application" | _ => "apply".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Dynamic system prompt construction +// --------------------------------------------------------------------------- + +const WEIGHT_MIN_SAMPLES: usize = 10; +const WEIGHT_HIGH_RATE: f64 = 0.7; +const WEIGHT_LOW_RATE: f64 = 0.4; + +const ISSUE_THRESHOLD: usize = 5; +const ISSUE_DUPLICATE_THRESHOLD: usize = 3; + +fn build_template_weight_hint(stats: &FeedbackStats) -> Option { + let mut lines = Vec::new(); + for ts in &stats.by_template { + if ts.total < WEIGHT_MIN_SAMPLES { + continue; + } + if ts.helpful_rate < WEIGHT_LOW_RATE { + lines.push(format!( + "- Avoid the \"{}\" template — users find it unhelpful.", + ts.template + )); + } else if ts.helpful_rate > WEIGHT_HIGH_RATE { + lines.push(format!( + "- The \"{}\" template works well — consider using it.", + ts.template + )); + } + } + if lines.is_empty() { + None + } else { + Some(format!( + "Template preferences based on user feedback:\n{}", + lines.join("\n") + )) + } +} + +fn build_issue_hint(stats: &FeedbackStats) -> Option { + let mut lines = Vec::new(); + for issue in &stats.common_issues { + match issue.reason.as_str() { + "too_easy" if issue.count >= ISSUE_THRESHOLD => { + lines.push( + "- Ask at a deeper level; avoid surface-level recall questions.".to_string(), + ); + } + "irrelevant" if issue.count >= ISSUE_THRESHOLD => { + lines.push( + "- The question MUST directly reference specific content from the excerpt." + .to_string(), + ); + } + "too_vague" if issue.count >= ISSUE_THRESHOLD => { + lines.push( + "- Be specific; reference exact concepts, terms, or claims from the text." + .to_string(), + ); + } + "duplicate" if issue.count >= ISSUE_DUPLICATE_THRESHOLD => { + lines.push( + "- Vary your question style across template kinds.".to_string(), + ); + } + _ => {} + } + } + if lines.is_empty() { + None + } else { + Some(format!( + "Additional rules based on past feedback:\n{}", + lines.join("\n") + )) + } +} + +pub fn build_system_prompt(stats: Option<&FeedbackStats>) -> String { + let mut prompt = BASE_SYSTEM_PROMPT.to_string(); + if let Some(s) = stats { + if let Some(hint) = build_template_weight_hint(s) { + prompt.push_str("\n\n"); + prompt.push_str(&hint); + } + if let Some(hint) = build_issue_hint(s) { + prompt.push_str("\n\n"); + prompt.push_str(&hint); + } + } + prompt +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::challenge_feedback::{IssueCount, TemplateStats}; + + #[test] + fn normalize_template_kind_aliases() { + assert_eq!(normalize_template_kind(Some("compare")), "compare"); + assert_eq!(normalize_template_kind(Some("comparison")), "compare"); + assert_eq!(normalize_template_kind(Some("critique")), "critique"); + assert_eq!(normalize_template_kind(Some("critical")), "critique"); + assert_eq!(normalize_template_kind(Some("transfer")), "transfer"); + assert_eq!(normalize_template_kind(Some("migration")), "transfer"); + assert_eq!(normalize_template_kind(Some("apply")), "apply"); + assert_eq!(normalize_template_kind(Some("application")), "apply"); + assert_eq!(normalize_template_kind(Some("unknown")), "apply"); + assert_eq!(normalize_template_kind(None), "apply"); + } + + fn make_stats( + templates: Vec<(&str, usize, usize)>, + issues: Vec<(&str, usize)>, + ) -> FeedbackStats { + let by_template: Vec = templates + .into_iter() + .map(|(name, helpful, not_helpful)| { + let total = helpful + not_helpful; + TemplateStats { + template: name.to_string(), + total, + helpful, + not_helpful, + helpful_rate: if total > 0 { + helpful as f64 / total as f64 + } else { + 0.0 + }, + } + }) + .collect(); + let common_issues = issues + .into_iter() + .map(|(reason, count)| IssueCount { + reason: reason.to_string(), + count, + }) + .collect(); + let helpful_count: usize = by_template.iter().map(|t: &TemplateStats| t.helpful).sum(); + let not_helpful_count: usize = by_template + .iter() + .map(|t: &TemplateStats| t.not_helpful) + .sum(); + let total = helpful_count + not_helpful_count; + FeedbackStats { + total_ratings: total, + helpful_count, + not_helpful_count, + helpful_rate: if total > 0 { + helpful_count as f64 / total as f64 + } else { + 0.0 + }, + by_template, + common_issues, + } + } + + #[test] + fn build_template_weight_hint_none_when_insufficient_data() { + let stats = make_stats(vec![("apply", 3, 2), ("compare", 1, 1)], vec![]); + assert!(build_template_weight_hint(&stats).is_none()); + } + + #[test] + fn build_template_weight_hint_surfaces_extreme_templates() { + let stats = make_stats( + vec![ + ("apply", 9, 1), // 10 samples, 0.9 rate → prefer + ("critique", 2, 10), // 12 samples, 0.17 rate → avoid + ("compare", 3, 2), // 5 samples → skip (below threshold) + ], + vec![], + ); + let hint = build_template_weight_hint(&stats).unwrap(); + assert!(hint.contains("\"apply\" template works well")); + assert!(hint.contains("Avoid the \"critique\"")); + assert!(!hint.contains("compare")); + } + + #[test] + fn build_issue_hint_none_when_no_issues() { + let stats = make_stats(vec![], vec![]); + assert!(build_issue_hint(&stats).is_none()); + } + + #[test] + fn build_issue_hint_triggers_on_threshold() { + let stats = make_stats( + vec![], + vec![ + ("too_easy", 6), + ("too_vague", 5), + ("irrelevant", 4), // below threshold + ("duplicate", 3), + ], + ); + let hint = build_issue_hint(&stats).unwrap(); + assert!(hint.contains("deeper level")); + assert!(hint.contains("Be specific")); + assert!(!hint.contains("MUST directly reference")); // irrelevant below threshold + assert!(hint.contains("Vary your question style")); + } + + #[test] + fn build_system_prompt_base_only_without_stats() { + let prompt = build_system_prompt(None); + assert_eq!(prompt, BASE_SYSTEM_PROMPT); + } + + #[test] + fn build_system_prompt_appends_hints() { + let stats = make_stats( + vec![("apply", 9, 1)], // 10 samples, high rate + vec![("too_easy", 7)], + ); + let prompt = build_system_prompt(Some(&stats)); + assert!(prompt.starts_with(BASE_SYSTEM_PROMPT)); + assert!(prompt.contains("Template preferences")); + assert!(prompt.contains("Additional rules")); + } +} diff --git a/src-tauri/src/challenge_review.rs b/src-tauri/src/challenge_review.rs index 237f974..f264ec7 100644 --- a/src-tauri/src/challenge_review.rs +++ b/src-tauri/src/challenge_review.rs @@ -82,27 +82,13 @@ pub fn apply_challenge_pass_blocking( Ok(outcome.maturity_change) } -// --- LLM:生成挑战问句 --- - -/// 与主流式隔离的 system 提示(英文),输出 JSON。 -const SYSTEM_CHALLENGE_GENERATE: &str = r#"You design ONE short challenge question to help the user revisit a saved thought from their notes. - -Pick the best template kind: -- "compare": contrast two ideas or test whether a distinction still holds in a scenario. -- "apply": ask them to apply the thought to a new concrete situation. -- "critique": challenge an implicit assumption politely. -- "transfer": ask whether an idea from domain A could inform domain B. +use crate::challenge_prompts::{ + self, candidate_degraded_question, depth_tone_line, generate_ui_locale_paragraph, + normalize_template_kind, ui_locale_is_en, ui_locale_is_zh, FALLBACK_CHALLENGE_QUESTION_EN, + FALLBACK_CHALLENGE_QUESTION_ZH, +}; -Rules: -- The question must be answerable in a few sentences; no multi-part essays. -- If the user message includes a "UI locale" line, write the `question` in that language (English vs Chinese) regardless of excerpt language. -- Otherwise match the thought excerpt language (Chinese excerpt → Chinese question; English → English). -- Respond with ONE JSON object only (no markdown fences, no prose). Keys (camelCase): - - "question": string (non-empty unless skipped) - - "templateKind": one of compare | apply | critique | transfer - - "skipped": boolean — true if the excerpt is too thin or unsafe to challenge; then set question to "". - -Example: {"question":"...","templateKind":"apply","skipped":false}"#; +// --- LLM:生成挑战问句 --- #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -131,6 +117,8 @@ pub struct GenerateChallengeQuestionArgs { pub marking_reason: Option, #[serde(default)] pub paired_excerpt: Option, + #[serde(default)] + pub thought_id: Option, } #[derive(Debug, Clone, Serialize)] @@ -196,36 +184,6 @@ pub struct EvaluateChallengeAnswerResponse { pub template_kind: Option, } -/// 通道一/二共用的降级问句(中文,与产品文档一致) -pub const FALLBACK_CHALLENGE_QUESTION_ZH: &str = "你之前写过这个想法,现在还同意这个观点吗?"; - -pub const FALLBACK_CHALLENGE_QUESTION_EN: &str = - "You wrote this idea before — do you still agree with it?"; - -pub(crate) fn ui_locale_is_zh(ui_locale: Option<&str>) -> bool { - matches!( - ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("zh" | "zh-cn" | "zh-hans" | "zh-hant" | "zh-tw") - ) -} - -fn ui_locale_is_en(ui_locale: Option<&str>) -> bool { - matches!( - ui_locale.map(|s| s.trim().to_ascii_lowercase()).as_deref(), - Some("en") | Some("en-us") | Some("en-gb") - ) -} - -/// 注入用户消息块,约束问句自然语言与 Knowforge 界面一致。 -fn generate_ui_locale_paragraph(ui_locale: Option<&str>) -> &'static str { - if ui_locale_is_zh(ui_locale) { - "UI locale: Chinese (Simplified). Write the JSON `question` field in natural Chinese (简体中文), even if the excerpt is in another language." - } else if ui_locale_is_en(ui_locale) { - "UI locale: English. Write the JSON `question` field in English, even if the excerpt is in another language." - } else { - "Language: If no UI locale was specified, match the thought excerpt language for the question." - } -} fn evaluate_ui_locale_paragraph(ui_locale: Option<&str>) -> &'static str { if ui_locale_is_zh(ui_locale) { @@ -269,69 +227,6 @@ fn resolve_depth_for_challenge(depth: Option, query_opt: Option<&str> } } -fn depth_tone_line(d: DepthMode) -> &'static str { - match d { - DepthMode::Shallow => "Keep the challenge question very short (one sentence).", - DepthMode::Medium => "Keep the challenge question concise (1-2 sentences).", - DepthMode::Deep => "You may use a slightly richer challenge question (still under 3 sentences).", - DepthMode::Auto => "Keep the challenge question concise (1-2 sentences).", - } -} - -fn candidate_degraded_question( - reason: &str, - _excerpt: &str, - paired: Option<&str>, - locale: Option<&str>, -) -> String { - let is_en = ui_locale_is_en(locale); - match reason { - "high_similarity" => { - if let Some(p) = paired { - if is_en { - format!("Your notes contain similar content in another file ({p}). What's the unique perspective in this paragraph?") - } else { - format!("你的笔记在另一个文件({p})中有类似内容。这段话的独特之处是什么?") - } - } else if is_en { - "Your notes contain similar paragraphs. What's the key difference between them?".to_string() - } else { - "你的笔记中有多段相似内容,它们的核心区别是什么?".to_string() - } - } - "semantic_isolated" => { - if is_en { - "This idea seems isolated from your other notes. What connections can you draw to other topics you've written about?".to_string() - } else { - "这个想法和你的其他笔记似乎没有关联。你能找到它与其他主题之间的联系吗?".to_string() - } - } - "cross_doc_recurrence" => { - if is_en { - "A similar concept appears across several of your notes. Has your understanding of it evolved over time?".to_string() - } else { - "你在多篇笔记中提到了类似的概念,你对它的理解有变化吗?".to_string() - } - } - _ => { - if is_en { - "What's the core insight in this paragraph, and do you still agree with it?".to_string() - } else { - "这段话的核心观点是什么?你现在还同意吗?".to_string() - } - } - } -} - -fn normalize_template_kind(raw: Option<&str>) -> String { - let s = raw.unwrap_or("apply").trim().to_ascii_lowercase(); - match s.as_str() { - "compare" | "comparison" => "compare".to_string(), - "critique" | "critical" => "critique".to_string(), - "transfer" | "migration" => "transfer".to_string(), - "apply" | "application" | _ => "apply".to_string(), - } -} /// 生成挑战问句(失败时 `should_skip=true` 供通道二静默) #[tauri::command] @@ -341,6 +236,8 @@ pub async fn generate_challenge_question( args: GenerateChallengeQuestionArgs, ) -> Result { let root = crate::lock_workspace_root(&workspace)?; + let root_for_stats = root.clone(); + let thought_id_for_dedup = args.thought_id.clone(); let ai = tauri::async_runtime::spawn_blocking(move || { let ai = vault_config::load_ai_config_internal(&root)?; Ok::<_, String>(ai) @@ -348,6 +245,24 @@ pub async fn generate_challenge_question( .await .map_err(|e| e.to_string())??; + let (recent_qs, feedback_stats) = tauri::async_runtime::spawn_blocking(move || { + let conn = match crate::vault_thoughts_db::open_thoughts_db(&root_for_stats) { + Ok(c) => c, + Err(_) => return (Vec::new(), None), + }; + let qs = match &thought_id_for_dedup { + Some(tid) if !tid.is_empty() => { + crate::challenge_feedback::query_recent_questions(&conn, tid, 5) + .unwrap_or_default() + } + _ => Vec::new(), + }; + let stats = crate::challenge_feedback::query_feedback_stats(&conn).ok(); + (qs, stats) + }) + .await + .unwrap_or((Vec::new(), None)); + let provider = match create_provider(&ai, None, http_client.inner()) { Ok(p) => p, Err(_) => { @@ -426,10 +341,17 @@ pub async fn generate_challenge_question( generate_ui_locale_paragraph(args.ui_locale.as_deref()) )); + if !recent_qs.is_empty() { + user_block.push_str("\n\nPrevious questions asked about this content (DO NOT repeat these):"); + for (i, q) in recent_qs.iter().enumerate() { + user_block.push_str(&format!("\n{}. \"{}\"", i + 1, q)); + } + } + let msgs = vec![ LlmChatMessage { role: "system".into(), - content: SYSTEM_CHALLENGE_GENERATE.to_string(), + content: challenge_prompts::build_system_prompt(feedback_stats.as_ref()), ..Default::default() }, LlmChatMessage { diff --git a/src-tauri/src/cognitive_push.rs b/src-tauri/src/cognitive_push.rs new file mode 100644 index 0000000..05240b7 --- /dev/null +++ b/src-tauri/src/cognitive_push.rs @@ -0,0 +1,287 @@ +//! 认知回顾桌面推送:基于 CognitiveReportForUi 生成紧凑摘要,驱动 OS 通知。 +//! 仅在应用运行时触发(启动检查 + 30 分钟定期检查)。 + +use crate::cognitive_report::{self, CognitiveReportForUi}; +use crate::vault_config::CognitiveConfig; +use chrono::{Datelike, Local, NaiveDate}; +use serde::Serialize; +use std::path::Path; + +/// 推送通知内容 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PushSummary { + pub title: String, + pub body: String, +} + +/// 判定是否需要发送周报 +fn should_send_weekly(last_sent: Option<&NaiveDate>) -> bool { + let now = Local::now().date_naive(); + match last_sent { + None => true, + Some(last) => (now - *last).num_days() >= 6, + } +} + +/// 判定是否需要发送月报 +fn should_send_monthly(last_sent: Option<&NaiveDate>) -> bool { + let now = Local::now().date_naive(); + match last_sent { + None => true, + Some(last) => last.month() != now.month() || last.year() != now.year(), + } +} + +/// 生成周报摘要;无活动返回 None(不推送) +fn build_weekly_summary(report: &CognitiveReportForUi) -> Option { + let activity = report.updated_this_month + report.new_this_month; + if activity == 0 { + return None; + } + + let title = "本周认知回顾".to_string(); + + // 从 timelines 中提取最活跃的 thought + let top_thought = report.timelines.first().map(|t| { + let excerpt: String = t.excerpt.chars().take(30).collect(); + let history_count = t.history.len(); + format!("「{excerpt}」经历 {history_count} 次变化") + }); + + let mut body_parts = vec![format!("本月活跃 {activity} 个想法")]; + + // 成熟度分布 + let total = report.maturity.seedling + report.maturity.growing + report.maturity.mature; + if total > 0 { + body_parts.push(format!( + "🌱{} 🌿{} 🌳{}", + report.maturity.seedling, report.maturity.growing, report.maturity.mature + )); + } + + if let Some(thought) = top_thought { + body_parts.push(thought); + } + + Some(PushSummary { + title, + body: body_parts.join(" · "), + }) +} + +/// 生成月报摘要;无晋升返回鼓励文案 +fn build_monthly_summary(report: &CognitiveReportForUi) -> Option { + let title = "本月认知回顾".to_string(); + + // 计算本月 vs 上月的成熟度变化 + let (promoted_to_growing, promoted_to_mature) = + if let Some(ref prev) = report.prev_month_maturity { + let to_growing = report.maturity.growing.saturating_sub(prev.growing); + let to_mature = report.maturity.mature.saturating_sub(prev.mature); + (to_growing, to_mature) + } else { + (0, 0) + }; + + let total_promotions = promoted_to_growing + promoted_to_mature; + + if total_promotions == 0 && report.new_this_month == 0 { + // 无晋升也无新增 → 鼓励文案 + let total = report.maturity.seedling + report.maturity.growing + report.maturity.mature; + if total == 0 { + return None; + } + return Some(PushSummary { + title, + body: format!("本月复习了 {total} 个想法,继续坚持!"), + }); + } + + let mut body_parts = vec![]; + + if total_promotions > 0 { + let mut promotion_desc = vec![]; + if promoted_to_growing > 0 { + promotion_desc.push(format!("{promoted_to_growing} 个🌱→🌿")); + } + if promoted_to_mature > 0 { + promotion_desc.push(format!("{promoted_to_mature} 个🌿→🌳")); + } + body_parts.push(format!("{} 理解加深", promotion_desc.join(","))); + } + + if report.new_this_month > 0 { + body_parts.push(format!("新增 {} 个想法", report.new_this_month)); + } + + // 成长最快的 thought + if let Some(ref top) = report.timelines.first() { + let excerpt: String = top.excerpt.chars().take(25).collect(); + body_parts.push(format!("成长最快:「{excerpt}」")); + } + + Some(PushSummary { + title, + body: body_parts.join(" · "), + }) +} + +/// 检查是否需要推送,返回待发送的通知列表 +pub fn check_and_build_notifications(root: &Path, config: &CognitiveConfig) -> Vec { + if !config.cognitive_push_enabled { + return vec![]; + } + + let report = match cognitive_report::generate_cognitive_report_blocking(root) { + Ok(r) => r, + Err(_) => return vec![], + }; + + let last_sent = config + .cognitive_push_last_sent + .as_ref() + .and_then(|s| NaiveDate::parse_from_str(&s[..10], "%Y-%m-%d").ok()); + + let mut notifications = vec![]; + + // 周报判定 + if matches!(config.cognitive_push_frequency.as_str(), "weekly" | "both") { + if should_send_weekly(last_sent.as_ref()) { + if let Some(summary) = build_weekly_summary(&report) { + notifications.push(summary); + } + } + } + + // 月报判定 + if matches!(config.cognitive_push_frequency.as_str(), "monthly" | "both") { + if should_send_monthly(last_sent.as_ref()) { + if let Some(summary) = build_monthly_summary(&report) { + notifications.push(summary); + } + } + } + + notifications +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_should_send_weekly_no_last_sent() { + assert!(should_send_weekly(None)); + } + + #[test] + fn test_should_send_weekly_recent() { + let now = Local::now().date_naive(); + assert!(!should_send_weekly(Some(&now))); + } + + #[test] + fn test_should_send_weekly_old() { + let now = Local::now().date_naive(); + let old = now - chrono::Duration::days(7); + assert!(should_send_weekly(Some(&old))); + } + + #[test] + fn test_should_send_monthly_same_month() { + let now = Local::now().date_naive(); + assert!(!should_send_monthly(Some(&now))); + } + + #[test] + fn test_should_send_monthly_different_month() { + let now = Local::now().date_naive(); + let old = now - chrono::Duration::days(35); + assert!(should_send_monthly(Some(&old))); + } + + #[test] + fn test_build_weekly_summary_no_activity() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 0, + updated_this_month: 0, + maturity: Default::default(), + prev_month_maturity: None, + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + assert!(build_weekly_summary(&report).is_none()); + } + + #[test] + fn test_build_weekly_summary_with_activity() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 2, + updated_this_month: 3, + maturity: Default::default(), + prev_month_maturity: None, + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + let summary = build_weekly_summary(&report).unwrap(); + assert!(summary.body.contains("5")); + } + + #[test] + fn test_build_monthly_summary_no_promotions() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 0, + updated_this_month: 0, + maturity: crate::cognitive_report::MaturityCounts { + seedling: 3, + growing: 1, + mature: 1, + }, + prev_month_maturity: Some(crate::cognitive_report::MaturityCounts { + seedling: 3, + growing: 1, + mature: 1, + }), + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + let summary = build_monthly_summary(&report).unwrap(); + assert!(summary.body.contains("坚持")); + } + + #[test] + fn test_build_monthly_summary_with_promotions() { + let report = CognitiveReportForUi { + scanned_files: 10, + total_thoughts: 5, + new_this_month: 1, + updated_this_month: 2, + maturity: crate::cognitive_report::MaturityCounts { + seedling: 2, + growing: 2, + mature: 1, + }, + prev_month_maturity: Some(crate::cognitive_report::MaturityCounts { + seedling: 3, + growing: 1, + mature: 1, + }), + total_ai_references: 0, + timelines: vec![], + monthly_snapshots: vec![], + }; + let summary = build_monthly_summary(&report).unwrap(); + assert!(summary.body.contains("🌿")); + assert!(summary.body.contains("1")); + } +} diff --git a/src-tauri/src/growth_story.rs b/src-tauri/src/growth_story.rs new file mode 100644 index 0000000..b8552fb --- /dev/null +++ b/src-tauri/src/growth_story.rs @@ -0,0 +1,336 @@ +//! Thought 成长故事:从 Thought 的 history 时间线构建可导出的成长旅程。 + +use crate::note_privacy; +use crate::thought_parser::{self, KfThoughtMeta, ThoughtMaturity}; +use crate::vault_context_search; +use chrono::{Datelike, NaiveDate, Utc}; +use serde::Serialize; +use std::path::Path; + +/// 成长故事摘要 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GrowthStory { + pub thought_id: String, + pub thought_title: String, + pub content_preview: String, + pub source_file: String, + pub created_at: String, + pub current_maturity: String, + pub journey: Vec, + pub total_challenges: usize, + pub total_days: usize, + pub pass_rate: f64, +} + +/// 成长旅程中的单个里程碑 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct JourneyMilestone { + pub date: String, + pub event_type: String, + pub description: String, +} + +/// 从 Markdown 文件中查找指定 thought 的元数据 +fn find_thought_meta(root: &Path, thought_id: &str) -> Result, String> { + let mut paths: Vec = Vec::new(); + vault_context_search::walk_markdown_files(root, root, &mut paths, 600)?; + + for abs in &paths { + let Some(rel) = vault_context_search::rel_path_from_root(root, abs) else { + continue; + }; + let bytes = std::fs::read(abs).map_err(|e| format!("reading {rel}: {e}"))?; + if bytes.len() > 512 * 1024 { + continue; + } + let Ok(text) = String::from_utf8(bytes) else { + continue; + }; + if note_privacy::markdown_treat_as_kf_private(&text) { + continue; + } + if !text.contains("kf-thoughts") { + continue; + } + let parsed = thought_parser::parse_note_thoughts_for_workspace(root, &rel, &text); + for meta in &parsed.meta { + if meta.id == thought_id { + return Ok(Some((meta.clone(), rel, text))); + } + } + } + Ok(None) +} + +/// 构建成长故事 +pub fn build_growth_story(root: &Path, thought_id: &str) -> Result { + let (meta, rel_path, _markdown) = find_thought_meta(root, thought_id)? + .ok_or_else(|| format!("thought {thought_id} not found"))?; + + let title = extract_title_from_body(&meta, root, &rel_path); + let content_preview = extract_content_preview(&meta, root, &rel_path); + + let maturity_str = match meta.maturity { + ThoughtMaturity::Seedling => "seedling", + ThoughtMaturity::Growing => "growing", + ThoughtMaturity::Mature => "mature", + }; + + let mut journey = Vec::new(); + + // created 事件 + journey.push(JourneyMilestone { + date: format_date_short(&meta.created), + event_type: "created".to_string(), + description: "开始追踪这个想法".to_string(), + }); + + // history 事件 + let mut challenge_count = 0usize; + let mut pass_count = 0usize; + + for entry in &meta.history { + let desc = match entry.entry_type.as_str() { + "created" => continue, // 已添加 + "substantial-change" => { + entry.diff_summary.clone().unwrap_or_else(|| "内容更新".to_string()) + } + "challenge-review-pass" => { + challenge_count += 1; + pass_count += 1; + format!("第 {} 次挑战通过", challenge_count) + } + "challenge-review-attempt" => { + challenge_count += 1; + format!("第 {} 次挑战尝试", challenge_count) + } + _ => entry.diff_summary.clone().unwrap_or_else(|| "事件".to_string()), + }; + journey.push(JourneyMilestone { + date: format_date_short(&entry.date), + event_type: entry.entry_type.clone(), + description: desc, + }); + } + + // 成熟度晋升事件(从 history 中的 challenge-review-pass 推断) + if meta.maturity != ThoughtMaturity::Seedling { + // 检查是否有晋升事件(通过 pass_count 推断) + if meta.challenge_pass_count >= 1 && meta.maturity as u8 >= ThoughtMaturity::Growing as u8 { + // 找到第一个 challenge-review-pass 的日期作为晋升到 Growing 的时间 + if let Some(first_pass) = meta.history.iter().find(|h| h.entry_type == "challenge-review-pass") { + journey.push(JourneyMilestone { + date: format_date_short(&first_pass.date), + event_type: "promoted".to_string(), + description: "🌱→🌿 理解加深".to_string(), + }); + } + } + if meta.maturity == ThoughtMaturity::Mature { + // 找到最后一个 challenge-review-pass 的日期作为晋升到 Mature 的时间 + if let Some(last_pass) = meta.history.iter().rev().find(|h| h.entry_type == "challenge-review-pass") { + journey.push(JourneyMilestone { + date: format_date_short(&last_pass.date), + event_type: "promoted".to_string(), + description: "🌿🌳 融会贯通".to_string(), + }); + } + } + } + + // 按日期排序 + journey.sort_by(|a, b| a.date.cmp(&b.date)); + + // 计算总天数 + let total_days = compute_total_days(&meta.created); + + // 计算通过率 + let pass_rate = if challenge_count > 0 { + pass_count as f64 / challenge_count as f64 + } else { + 0.0 + }; + + Ok(GrowthStory { + thought_id: meta.id, + thought_title: title, + content_preview, + source_file: rel_path, + created_at: meta.created, + current_maturity: maturity_str.to_string(), + journey, + total_challenges: challenge_count, + total_days, + pass_rate, + }) +} + +/// 从 thought body 中提取标题(第一行或前 50 字符) +fn extract_title_from_body(meta: &KfThoughtMeta, root: &Path, rel_path: &str) -> String { + let body = read_thought_body(root, rel_path, &meta.id); + if body.is_empty() { + return meta.id.clone(); + } + let first_line = body.lines().next().unwrap_or("").trim(); + if first_line.is_empty() { + meta.id.clone() + } else if first_line.len() > 50 { + format!("{}…", &first_line[..50]) + } else { + first_line.to_string() + } +} + +/// 提取内容预览(前 100 字符) +fn extract_content_preview(meta: &KfThoughtMeta, root: &Path, rel_path: &str) -> String { + let body = read_thought_body(root, rel_path, &meta.id); + if body.is_empty() { + return String::new(); + } + let preview: String = body.chars().take(100).collect(); + if body.len() > 100 { + format!("{preview}…") + } else { + preview + } +} + +/// 从 SQLite 读取 thought body +fn read_thought_body(root: &Path, _rel_path: &str, thought_id: &str) -> String { + let conn = match crate::vault_thoughts_db::open_thoughts_db(root) { + Ok(c) => c, + Err(_) => return String::new(), + }; + crate::vault_thoughts_db::get_body(&conn, thought_id) + .ok() + .flatten() + .unwrap_or_default() +} + +/// 格式化日期为短格式 "M/D" +fn format_date_short(rfc3339: &str) -> String { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(rfc3339) { + let local = dt.with_timezone(&chrono::Local); + format!("{}/{}", local.month(), local.day()) + } else if rfc3339.len() >= 10 { + // 尝试 YYYY-MM-DD 格式 + NaiveDate::parse_from_str(&rfc3339[..10], "%Y-%m-%d") + .map(|d| format!("{}/{}", d.month(), d.day())) + .unwrap_or_else(|_| rfc3339[..10].to_string()) + } else { + rfc3339.to_string() + } +} + +/// 计算从创建到现在的天数 +fn compute_total_days(created_at: &str) -> usize { + let created = chrono::DateTime::parse_from_rfc3339(created_at) + .ok() + .map(|dt| dt.date_naive()); + let now = Utc::now().date_naive(); + match created { + Some(c) => (now - c).num_days().max(0) as usize, + None => 0, + } +} + +/// 生成 Markdown 格式的成长故事 +pub fn to_markdown(story: &GrowthStory) -> String { + let maturity_emoji = match story.current_maturity.as_str() { + "seedling" => "🌱", + "growing" => "🌿", + "mature" => "🌳", + _ => "🌱", + }; + let maturity_label = match story.current_maturity.as_str() { + "seedling" => "萌芽", + "growing" => "成长", + "mature" => "融会贯通", + _ => "萌芽", + }; + + let mut md = format!("## {} {} — 成长故事({})\n\n", maturity_emoji, story.thought_title, maturity_label); + + if !story.content_preview.is_empty() { + md.push_str(&format!("> {}\n\n", story.content_preview)); + } + + md.push_str(&format!( + "从 {} 开始追踪,历时 {} 天:\n\n", + format_date_short(&story.created_at), + story.total_days + )); + + for m in &story.journey { + md.push_str(&format!("- {} {}\n", m.date, m.description)); + } + + md.push_str(&format!( + "\n共经历 {} 次挑战 · 通过率 {:.0}% · 历时 {} 天\n", + story.total_challenges, + story.pass_rate * 100.0, + story.total_days + )); + + md.push_str("\n---\n*Generated by KnowForge*\n"); + + md +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_date_short() { + assert_eq!(format_date_short("2026-07-15T10:30:00+08:00"), "7/15"); + } + + #[test] + fn test_format_date_short_plain() { + assert_eq!(format_date_short("2026-01-05"), "1/5"); + } + + #[test] + fn test_compute_total_days() { + let now = Utc::now(); + let created = (now - chrono::Duration::days(10)).to_rfc3339(); + let days = compute_total_days(&created); + assert!((9..=11).contains(&days)); + } + + #[test] + fn test_to_markdown() { + let story = GrowthStory { + thought_id: "test".to_string(), + thought_title: "Test Thought".to_string(), + content_preview: "Preview".to_string(), + source_file: "test.md".to_string(), + created_at: "2026-07-01T00:00:00+00:00".to_string(), + current_maturity: "growing".to_string(), + journey: vec![ + JourneyMilestone { + date: "7/1".to_string(), + event_type: "created".to_string(), + description: "开始追踪".to_string(), + }, + JourneyMilestone { + date: "7/8".to_string(), + event_type: "challenge-review-pass".to_string(), + description: "第 1 次挑战通过".to_string(), + }, + ], + total_challenges: 3, + total_days: 15, + pass_rate: 0.6667, + }; + let md = to_markdown(&story); + assert!(md.contains("成长故事")); + assert!(md.contains("Test Thought")); + assert!(md.contains("7/1")); + assert!(md.contains("7/8")); + assert!(md.contains("KnowForge")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2b5083b..800761e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,9 +14,12 @@ use tauri::{AppHandle, Emitter}; mod ai_conversations; mod challenge_feedback; +mod challenge_prompts; mod challenge_review; mod depth_decisions; mod cognitive_report; +mod cognitive_push; +mod growth_story; mod knowforge_analytics; mod llm; mod note_privacy; @@ -271,6 +274,42 @@ async fn cleanup_expired_tool_results(workspace_root: &Path) { } } +/// 检查并发送认知回顾推送通知 +async fn check_and_send_cognitive_push(root: &Path, app_handle: &tauri::AppHandle) { + let config = match vault_config::load_cognitive_merged(root) { + Ok(c) => c, + Err(_) => return, + }; + let notifications = cognitive_push::check_and_build_notifications(root, &config); + + if notifications.is_empty() { + return; + } + + use tauri_plugin_notification::NotificationExt; + for n in ¬ifications { + let _ = app_handle + .notification() + .builder() + .title(&n.title) + .body(&n.body) + .show(); + } + + // 更新 last_sent 时间戳 + let now_str = chrono::Local::now().to_rfc3339(); + let patch = vault_config::VaultConfigPatch { + cognitive: Some(vault_config::CognitiveConfigPatch { + cognitive_push_last_sent: Some(Some(now_str)), + ..Default::default() + }), + ..Default::default() + }; + if let Err(e) = vault_config::save_patch(root, patch) { + eprintln!("[cognitive_push] failed to update last_sent: {e}"); + } +} + #[tauri::command] async fn open_workspace( root: String, @@ -377,6 +416,21 @@ async fn open_workspace( cleanup_expired_tool_results(&cleanup_root).await; }); + // 认知回顾推送:启动时检查一次,之后每 30 分钟定期检查 + { + let push_root = canonical_root.clone(); + let push_app = app_handle.clone(); + tokio::spawn(async move { + check_and_send_cognitive_push(&push_root, &push_app).await; + let mut interval = + tokio::time::interval(tokio::time::Duration::from_secs(30 * 60)); + loop { + interval.tick().await; + check_and_send_cognitive_push(&push_root, &push_app).await; + } + }); + } + Ok(nodes) } @@ -1532,6 +1586,21 @@ async fn search_thought_for_invite( .map_err(|e| e.to_string())? } +#[tauri::command] +async fn get_thought_growth_story( + thought_id: String, + state: tauri::State<'_, WorkspaceState>, +) -> Result { + let root = lock_workspace_root(&state)?; + let tid = thought_id.trim().to_string(); + if tid.is_empty() { + return Err("thought_id is empty".to_string()); + } + tauri::async_runtime::spawn_blocking(move || growth_story::build_growth_story(&root, &tid)) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] async fn search_workspace_text( args: workspace_text_search::SearchWorkspaceTextArgs, @@ -1766,6 +1835,44 @@ async fn dismiss_latent_candidate( .map_err(|e| e.to_string())? } +#[tauri::command] +async fn check_cognitive_push_now( + state: tauri::State<'_, WorkspaceState>, + app_handle: tauri::AppHandle, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + let config = vault_config::load_cognitive_merged(&root) + .map_err(|e| format!("failed to load config: {e}"))?; + let notifications = cognitive_push::check_and_build_notifications(&root, &config); + + use tauri_plugin_notification::NotificationExt; + for n in ¬ifications { + let _ = app_handle + .notification() + .builder() + .title(&n.title) + .body(&n.body) + .show(); + } + + // 更新 last_sent + if !notifications.is_empty() { + let now_str = chrono::Local::now().to_rfc3339(); + let patch = vault_config::VaultConfigPatch { + cognitive: Some(vault_config::CognitiveConfigPatch { + cognitive_push_last_sent: Some(Some(now_str)), + ..Default::default() + }), + ..Default::default() + }; + if let Err(e) = vault_config::save_patch(&root, patch) { + eprintln!("[cognitive_push] failed to update last_sent: {e}"); + } + } + + Ok(notifications) +} + pub fn run() { tauri::Builder::default() .manage(WorkspaceState::default()) @@ -1786,6 +1893,7 @@ pub fn run() { Arc::new(tools::ToolContextFactory::new(audit_sink, privacy_filter)) }) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_notification::init()) .invoke_handler(tauri::generate_handler![ open_workspace, refresh_md_tree, @@ -1833,6 +1941,8 @@ pub fn run() { apply_challenge_pass_to_thought, append_ai_thought_reference, cognitive_report::generate_cognitive_report, + check_cognitive_push_now, + get_thought_growth_story, understanding_graph::scan_understanding_graph, challenge_review::generate_challenge_question, challenge_review::evaluate_challenge_answer, diff --git a/src-tauri/src/vault_config.rs b/src-tauri/src/vault_config.rs index 54f7117..b27ceda 100644 --- a/src-tauri/src/vault_config.rs +++ b/src-tauri/src/vault_config.rs @@ -408,6 +408,15 @@ pub struct CognitiveConfig { /// 忽略气泡后的冷却分钟数(默认 15) #[serde(default = "default_writing_coach_cooldown_minutes")] pub writing_coach_cooldown_minutes: u32, + /// 认知回顾推送总开关(默认关) + #[serde(default)] + pub cognitive_push_enabled: bool, + /// 推送频率:"weekly" | "monthly" | "both"(默认 both) + #[serde(default = "default_cognitive_push_frequency")] + pub cognitive_push_frequency: String, + /// 上次推送时间(ISO 8601),用于调度判定 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cognitive_push_last_sent: Option, } fn default_challenge_review_cap_independent() -> u32 { @@ -450,6 +459,10 @@ fn default_writing_coach_cooldown_minutes() -> u32 { 15 } +fn default_cognitive_push_frequency() -> String { + "both".to_string() +} + impl Default for CognitiveConfig { fn default() -> Self { Self { @@ -471,6 +484,9 @@ impl Default for CognitiveConfig { writing_coach_term_min_chars: default_writing_coach_term_min_chars(), writing_coach_bubble_seconds: default_writing_coach_bubble_seconds(), writing_coach_cooldown_minutes: default_writing_coach_cooldown_minutes(), + cognitive_push_enabled: false, + cognitive_push_frequency: default_cognitive_push_frequency(), + cognitive_push_last_sent: None, } } } @@ -553,6 +569,9 @@ struct CognitiveDiskPartial { writing_coach_term_min_chars: Option, writing_coach_bubble_seconds: Option, writing_coach_cooldown_minutes: Option, + cognitive_push_enabled: Option, + cognitive_push_frequency: Option, + cognitive_push_last_sent: Option, } // --- 网络搜索配置 --- @@ -713,6 +732,9 @@ pub struct CognitiveConfigPatch { pub writing_coach_term_min_chars: Option, pub writing_coach_bubble_seconds: Option, pub writing_coach_cooldown_minutes: Option, + pub cognitive_push_enabled: Option, + pub cognitive_push_frequency: Option, + pub cognitive_push_last_sent: Option>, } #[derive(Debug, Deserialize, Default)] @@ -768,6 +790,10 @@ pub struct CognitiveConfigForUi { pub writing_coach_term_min_chars: u32, pub writing_coach_bubble_seconds: u32, pub writing_coach_cooldown_minutes: u32, + pub cognitive_push_enabled: bool, + pub cognitive_push_frequency: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub cognitive_push_last_sent: Option, } #[derive(Debug, Serialize)] @@ -1153,6 +1179,15 @@ fn merge_cognitive_from_disk_partial( if let Some(v) = partial.writing_coach_cooldown_minutes { cfg.writing_coach_cooldown_minutes = v; } + if let Some(v) = partial.cognitive_push_enabled { + cfg.cognitive_push_enabled = v; + } + if let Some(v) = partial.cognitive_push_frequency { + cfg.cognitive_push_frequency = v; + } + if partial.cognitive_push_last_sent.is_some() { + cfg.cognitive_push_last_sent = partial.cognitive_push_last_sent; + } normalize_cognitive(&mut cfg); cfg } @@ -1191,6 +1226,9 @@ fn to_cognitive_for_ui(cfg: CognitiveConfig) -> CognitiveConfigForUi { writing_coach_term_min_chars: cfg.writing_coach_term_min_chars, writing_coach_bubble_seconds: cfg.writing_coach_bubble_seconds, writing_coach_cooldown_minutes: cfg.writing_coach_cooldown_minutes, + cognitive_push_enabled: cfg.cognitive_push_enabled, + cognitive_push_frequency: cfg.cognitive_push_frequency.clone(), + cognitive_push_last_sent: cfg.cognitive_push_last_sent.clone(), } } @@ -1254,6 +1292,15 @@ fn apply_cognitive_patch(cfg: &mut CognitiveConfig, patch: CognitiveConfigPatch) if let Some(v) = patch.writing_coach_cooldown_minutes { cfg.writing_coach_cooldown_minutes = v; } + if let Some(v) = patch.cognitive_push_enabled { + cfg.cognitive_push_enabled = v; + } + if let Some(v) = patch.cognitive_push_frequency { + cfg.cognitive_push_frequency = v; + } + if let Some(s) = patch.cognitive_push_last_sent { + cfg.cognitive_push_last_sent = s; + } } fn apply_ai_patch(cfg: &mut AiConfig, patch: AiConfigPatch) { diff --git a/src-tauri/src/writing_coach.rs b/src-tauri/src/writing_coach.rs index dbd6f05..a7fea9f 100644 --- a/src-tauri/src/writing_coach.rs +++ b/src-tauri/src/writing_coach.rs @@ -11,7 +11,6 @@ use crate::llm::LlmChatMessage; use tokio_util::sync::CancellationToken; use crate::lock_workspace_root; use crate::note_privacy; -use crate::challenge_review; use crate::thought_retrieval::{self, SearchThoughtArgs}; use crate::vault_config::{self, AiConfig, DepthMode}; use crate::vault_context_search::{self, SearchWorkspaceContextArgs, SearchWorkspaceLimits}; @@ -353,7 +352,7 @@ fn filter_response( if reasoning_questions.is_empty() { // 模型输出若全被红线过滤,给一条中性提问,避免空白浮层(语言随界面) - let q = if challenge_review::ui_locale_is_zh(prep.ui_locale.as_deref()) { + let q = if crate::challenge_prompts::ui_locale_is_zh(prep.ui_locale.as_deref()) { FALLBACK_REASONING_QUESTION_ZH } else { FALLBACK_REASONING_QUESTION_EN diff --git a/src/components/AiLlmSettingsModal.tsx b/src/components/AiLlmSettingsModal.tsx index d4a6a9a..f4ee95f 100644 --- a/src/components/AiLlmSettingsModal.tsx +++ b/src/components/AiLlmSettingsModal.tsx @@ -140,6 +140,8 @@ type FormState = { independentReviewEnabled: boolean; challengeReviewDailyCapIndependent: string; challengeReviewDailyCapInline: string; + cognitivePushEnabled: boolean; + cognitivePushFrequency: string; semanticEnabled: boolean; semanticAutoIndex: boolean; semanticSearchWeight: string; @@ -248,6 +250,8 @@ function defaultForm(): FormState { independentReviewEnabled: false, challengeReviewDailyCapIndependent: "3", challengeReviewDailyCapInline: "2", + cognitivePushEnabled: false, + cognitivePushFrequency: "both", semanticEnabled: true, semanticAutoIndex: true, semanticSearchWeight: "0.6", @@ -360,6 +364,8 @@ function vaultConfigToForm(cfg: VaultConfigForUi): FormState { independentReviewEnabled: cognitive.independentReviewEnabled === true, challengeReviewDailyCapIndependent: String(cognitive.challengeReviewDailyCapIndependent ?? 3), challengeReviewDailyCapInline: String(cognitive.challengeReviewDailyCapInline ?? 2), + cognitivePushEnabled: cognitive.cognitivePushEnabled === true, + cognitivePushFrequency: cognitive.cognitivePushFrequency ?? "both", semanticEnabled: semantic.enabled !== false, semanticAutoIndex: semantic.autoIndexOnSave !== false, semanticSearchWeight: String(semantic.searchWeight ?? 0.6), @@ -779,6 +785,8 @@ export function AiLlmSettingsModal({ independentReviewEnabled: form.independentReviewEnabled, challengeReviewDailyCapIndependent: capInd, challengeReviewDailyCapInline: capInline, + cognitivePushEnabled: form.cognitivePushEnabled, + cognitivePushFrequency: form.cognitivePushFrequency, }, semantic: { enabled: form.semanticEnabled, @@ -1609,6 +1617,36 @@ export function AiLlmSettingsModal({

{t("settings.challengeReviewDailyCapHint")}

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

{t("settings.cognitivePushHint")}

+
+ 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", " 次挑战")} + +
+ + + +
+ + +
+
+ )} +
+
+ ); +} 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")} + + · + +
@@ -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#" + + + + + + +
+
+
{emoji}
+
{title}
+
{label}
+
+
+ {journey} +
+
+ 经历 {challenges} 次挑战 · 通过率 {pass_rate:.0}% · 历时 {days} 天 +
+ +
+ +"#, + emoji = maturity_emoji, + title = story.thought_title, + label = maturity_label, + journey = journey_html, + challenges = story.total_challenges, + pass_rate = story.pass_rate * 100.0, + days = story.total_days + ) +} + /// 生成 Markdown 格式的成长故事 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 ? ( +
+ +
+ +
+
+ ) : null} {showTriggerBtn ? ( + {(() => { + // Check if thought is private + const isPrivate = isPathKfPrivate + && !detail.standalone + && detail.noteRelPath + && isPathKfPrivate(detail.noteRelPath); + if (isPrivate) return null; + return ( + + ); + })()}
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 && ( + + )} ))} 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) => ( -
  • +
  • + {currentItem.sourceType === "candidate" && currentItem.candidateId ? ( + + ) : items.length > 1 ? ( + + ) : null} {openMarkdownTab ? ( + ) : null} diff --git a/src/locales/en.json b/src/locales/en.json index 2c1ce2c..41c94dd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -413,6 +413,7 @@ "startRound": "Start challenge", "generating": "Generating question…", "skipItem": "Skip", + "abandon": "Give up", "submit": "Submit answer", "continueNext": "Continue to next", "dueLabel": "{{days}}d overdue", diff --git a/src/locales/zh.json b/src/locales/zh.json index 62944ba..3bae27f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -413,6 +413,7 @@ "startRound": "开始挑战", "generating": "正在生成问题…", "skipItem": "换一个", + "abandon": "放弃本题", "submit": "提交回答", "continueNext": "继续下一条", "dueLabel": "已过期 {{days}} 天", From 77fd51929a8958f685416aa213439d0c36cd7935 Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Mon, 13 Jul 2026 19:15:24 +0800 Subject: [PATCH 18/19] fix(latent): add filter versioning to invalidate stale candidates Problem: candidates generated before new content filters (table, frontmatter, heading-only) were added remain in the DB and still show table-heavy content in the review queue. Solution: - Add FILTER_VERSION constant (bumped to 2) and latent_meta table - On workspace open, check stored version vs current; if mismatched, clear non-dismissed candidates and re-scan with updated filters - Strengthen table detection: any line with |---| triggers skip, or >30% pipe-containing lines (was 50%) Co-authored-by: Claude (Opus 4.6) --- src-tauri/src/latent_paragraphs.rs | 62 ++++++++++++++++++++++++------ src-tauri/src/lib.rs | 15 ++++++-- 2 files changed, 61 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs index 8bc55d4..4258f7a 100644 --- a/src-tauri/src/latent_paragraphs.rs +++ b/src-tauri/src/latent_paragraphs.rs @@ -14,6 +14,9 @@ const MAX_CANDIDATES: usize = 500; const MIN_CHUNK_CHARS: usize = 50; const EXCERPT_LEN: usize = 200; +/// Bump this when filter logic changes to invalidate cached candidates. +const FILTER_VERSION: i64 = 2; + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct CandidateForUi { @@ -66,12 +69,43 @@ pub fn init_candidates_schema(conn: &Connection) -> Result<(), String> { CREATE INDEX IF NOT EXISTS idx_tc_rel_path ON thought_candidates(rel_path); CREATE INDEX IF NOT EXISTS idx_tc_reason ON thought_candidates(marking_reason); CREATE INDEX IF NOT EXISTS idx_tc_chunk_id ON thought_candidates(chunk_id); + CREATE TABLE IF NOT EXISTS latent_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); "#, ) .map_err(|e| format!("init thought_candidates schema: {e}"))?; Ok(()) } +/// Check if the stored filter version matches the current FILTER_VERSION. +/// If outdated, clear all non-dismissed/non-promoted candidates so a fresh scan runs. +pub fn invalidate_if_filter_changed(conn: &Connection) -> Result { + let stored: i64 = conn + .query_row( + "SELECT CAST(value AS INTEGER) FROM latent_meta WHERE key = 'filter_version'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + if stored == FILTER_VERSION { + return Ok(false); + } + conn.execute( + "DELETE FROM thought_candidates WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL", + [], + ) + .map_err(|e| format!("clear outdated candidates: {e}"))?; + conn.execute( + "INSERT OR REPLACE INTO latent_meta (key, value) VALUES ('filter_version', ?1)", + params![FILTER_VERSION.to_string()], + ) + .map_err(|e| format!("update filter_version: {e}"))?; + eprintln!("[latent_paragraphs] filter version changed ({stored} → {FILTER_VERSION}), cleared old candidates"); + Ok(true) +} + // --------------------------------------------------------------------------- // Heuristic filters // --------------------------------------------------------------------------- @@ -176,22 +210,25 @@ 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. +/// Markdown table detection. Skip if any of: +/// - Contains a table separator row (e.g. `|---|---|`) +/// - > 30% of non-empty lines contain pipe `|` characters fn is_table_heavy(text: &str) -> bool { let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.len() < 2 { return false; } - 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 + // Fast path: if any line looks like a table separator, it's a table + let has_separator = lines.iter().any(|l| { + let t = l.trim(); + t.contains("|") && t.contains("---") + }); + if has_separator { + return true; + } + // Slow path: count lines with pipe chars + let table_lines = lines.iter().filter(|l| l.trim().contains('|')).count(); + table_lines * 100 / lines.len() > 30 } /// YAML frontmatter block: starts with `---` and ends with `---` or `...` @@ -1039,7 +1076,8 @@ mod tests { #[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."; + // Only 1 out of 5 lines contains `|` (20%), below the 30% threshold + let prose = "This is a paragraph about Unix pipes. We use | to chain commands.\nAnother line of normal prose about topics.\nA third line discussing ideas and concepts in detail.\nFourth line with more context about the subject.\nFifth line wrapping up the discussion on this matter."; assert!(!should_skip_chunk(prose), "prose mentioning | should not be skipped"); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c0c418c..dd1efd5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -416,8 +416,8 @@ 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. + // Latent paragraph scan: run when embedding index exists but candidates + // are empty or were generated by an outdated filter version. { let scan_root = canonical_root.clone(); let scan_app = app_handle.clone(); @@ -430,6 +430,11 @@ async fn open_workspace( .query_row("SELECT count(*) FROM doc_chunks", [], |r| r.get::<_, i64>(0)) .unwrap_or(0) > 0; + if !has_chunks { + return; + } + // Check filter version — clears outdated candidates if needed + let version_changed = latent_paragraphs::invalidate_if_filter_changed(&conn).unwrap_or(false); let has_candidates: bool = conn .query_row( "SELECT count(*) FROM thought_candidates WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL", @@ -438,9 +443,11 @@ async fn open_workspace( ) .unwrap_or(0) > 0; - if has_chunks && !has_candidates { + if !has_candidates { use tauri::Manager; - eprintln!("[open_workspace] embedding index exists but no latent candidates, triggering scan"); + eprintln!( + "[open_workspace] triggering latent scan (version_changed={version_changed}, no active candidates)" + ); if let Some(ec) = scan_app.try_state::>() { if let Err(e) = latent_paragraphs::scan_vault(&conn, &ec, &scan_root) { eprintln!("[open_workspace] latent scan error: {e}"); From 7e9f1af463d64da3bb05faa8e67877402bd4e61c Mon Sep 17 00:00:00 2001 From: donfaquir <1458918806@qq.com> Date: Tue, 14 Jul 2026 15:43:40 +0800 Subject: [PATCH 19/19] revert(ui): restore graph/topic-network entry in ActivityBar Co-authored-by: Claude (Opus 4.6) --- src/App.tsx | 15 ++++++++++++++- src/components/ActivityBar.tsx | 25 ++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 41bb05c..8eb5d0a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ 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"; @@ -1332,7 +1333,19 @@ function App() {
    - {leftPanelView === "thoughts" ? ( + {leftPanelView === "graph" ? ( +
    + { + setLeftPanelView("files"); + void onOpenCoachMarkdownPath(relPath); + }} + /> +
    + ) : leftPanelView === "thoughts" ? ( thoughtManagementSessionActive ? (
    + + + + + + ); +} + function ThoughtsIcon() { return ( React.JSX.Element> = { files: FilesIcon, + graph: GraphIcon, thoughts: ThoughtsIcon, }; const VIEW_I18N_KEYS: Record = { files: "activityBar.files", + graph: "activityBar.graph", thoughts: "activityBar.thoughts", };