diff --git a/.opencode/plugins/worklog-session-start.js b/.opencode/plugins/worklog-session-start.js new file mode 100644 index 0000000..b6b41aa --- /dev/null +++ b/.opencode/plugins/worklog-session-start.js @@ -0,0 +1,25 @@ +import { mkdirSync, appendFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { join } from "node:path"; + +export default async ({ directory }) => { + // Write session header to worklog on plugin init (session start) + const worklogDir = join(directory, "worklog"); + mkdirSync(worklogDir, { recursive: true }); + + const now = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; + const time = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; + const sessionId = Math.random().toString(36).slice(2, 10); + + let branch = "none"; + try { + branch = execSync("git branch --show-current", { cwd: directory, encoding: "utf8" }).trim() || "none"; + } catch {} + + const header = `\n## ${date} ${time} · session:${sessionId} · branch:${branch}\n`; + appendFileSync(join(worklogDir, `${date}.md`), header); + + return {}; +}; diff --git a/.opencode/worklog-instructions.md b/.opencode/worklog-instructions.md new file mode 100644 index 0000000..a9ce53b --- /dev/null +++ b/.opencode/worklog-instructions.md @@ -0,0 +1,29 @@ +## Worklog 工作日志习惯 + +每个 session 必须维护 `worklog/YYYY-MM-DD.md`,让用户次日打开就能续接工作。 + +### Session 启动时(第一次回应用户之前) + +必读最近一份 worklog 获取上下文: +1. 先读 `worklog/<今天>.md`;如不存在或为空,读 `ls -1t worklog/*.md | head -2` 找最近 1-2 天的文件 +2. 重点看最近 session 段落里的「下一步」字段,作为接续点 +3. worklog-session-start 插件已自动在当日 worklog 写入本 session 的段落头(`## 时间 · session:xxx · branch:xxx`),无需手写 + +### 工作进行中(关键节点主动 append) + +以下时机必须在当前 session 段落下 append 一段记录到当日 worklog: +- 一个任务被标记 completed 时 +- 做出重要架构/技术决策时 +- 遇到无法自行解决的阻塞(需要用户决策、外部依赖)时 +- Session 即将结束、要交接给用户时 + +### Append 格式 + +``` +### HH:MM <一行总结,10 字内> +- **做了什么**: 简述动作和涉及的文件 +- **为什么**: 动机、约束、上下文(用户次日看时能复原决策) +- **下一步**: 明确的接续动作,让下个 session 知道从哪里开始 +``` + +「下一步」是核心字段——次日新 session 启动后会优先读它来决定接什么。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 925b753..f330382 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). +## [0.7.6] - 2026-07-15 + +### Added +- Practice Mode: new activity bar entry with routing, DiscoveryPane, PracticeSourcePreview (dual-pane), and PracticeReviewPane +- Review completion transition with daily picks in Practice Mode +- Discovery batch operations: multi-select, batch dismiss/promote +- Discovery detail views by candidate type +- Backend commands: `list_discovery_candidates` and `batch_dismiss_candidates` +- Latent paragraphs: embedding-based paragraph candidate engine +- Challenge review: support latent paragraph candidates in review queue +- Challenge: question quality feedback loop +- Review: show related documents for latent paragraph candidates +- Review: abandon button in QA phase to return to pick +- Review push notifications and thought growth story export +- AI degraded guidance and export enhancements (spec-1c, spec-3b) +- AI guide: show setup guidance when LLM is not configured +- Onboarding: replace step 4 tips with dynamic discovery card +- SM-2 algorithm upgrade for challenge scheduling +- i18n: en/zh translations for practice, discovery, reviewReminder + +### Changed +- Right panel: remove review tab, redirect to Practice Mode +- App.tsx refactored: extract ContentArea, EditorView, AppTopToolbar and hooks +- Cognitive report panel split into card-based subcomponents +- Review: replace inline challenge Q&A with lightweight reminder +- Graph, topic-network and skills modules frozen (chore) + +### Fixed +- Agent loop: prevent watchdog timeout during long async operations +- Latent: trigger scan on workspace open when candidates table is empty +- Latent: add filter versioning to invalidate stale candidates +- Review: deduplicate prefetch and on-demand question generation +- Review: pre-generate challenge questions to eliminate wait time +- Graph/topic-network entry restored in ActivityBar +- Suppress compiler warnings (unused imports, variables, dead code) + ## [0.7.5] - 2026-07-08 ### Added diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..25e97a6 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".opencode/worklog-instructions.md"] +} diff --git a/package.json b/package.json index 7227fc1..99d67a1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "knowforge", "private": true, - "version": "0.7.5", + "version": "0.7.6", "license": "Apache-2.0", "repository": { "type": "git", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 974066a..5819f0b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "knowforge" -version = "0.7.5" +version = "0.7.6" dependencies = [ "aho-corasick", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3d3f34f..d266acd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "knowforge" -version = "0.7.5" +version = "0.7.6" description = "Knowforge desktop app" authors = ["caichangqing"] edition = "2024" diff --git a/src-tauri/src/discovery_confirm.rs b/src-tauri/src/discovery_confirm.rs new file mode 100644 index 0000000..f3d4048 --- /dev/null +++ b/src-tauri/src/discovery_confirm.rs @@ -0,0 +1,577 @@ +//! LLM-assisted discovery confirmation (Spec 11). +//! +//! After the vector-based candidate detection pipeline produces candidates, +//! this module sends batches to an LLM for semantic verification — filtering +//! false positives and generating human-readable recommendation reasons. + +use std::sync::Arc; + +use chrono::Utc; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; + +use crate::llm::{create_provider, CompletionOverrides, LlmChatMessage}; +use crate::vault_config; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Maximum candidates to confirm per single LLM call. +#[allow(dead_code)] +const BATCH_SIZE: usize = 5; + +/// Maximum confirmations allowed per calendar day. +#[allow(dead_code)] +const DAILY_CAP: usize = 30; + +/// Cached confirmation expires after this many days. +const CACHE_VALID_DAYS: i64 = 7; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Input data for a single candidate to be confirmed. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CandidateToConfirm { + pub id: String, + pub rel_path: String, + pub excerpt: String, + pub marking_reason: String, + pub similarity_score: Option, + pub paired_rel_path: Option, + pub paired_excerpt: Option, + pub cluster_doc_count: Option, +} + +/// LLM verdict for a single candidate. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmResult { + pub candidate_id: String, + pub verdict: String, // "confirmed" | "downgraded" | "rejected" + pub reason: String, // 50-100 char recommendation reason +} + +/// Batch response wrapper used when parsing LLM JSON output. +#[derive(Debug, Deserialize)] +struct LlmConfirmResponse { + results: Vec, +} + +#[derive(Debug, Deserialize)] +struct LlmSingleVerdict { + /// 1-indexed candidate number from the prompt + #[serde(alias = "candidate")] + index: usize, + verdict: String, + reason: String, +} + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- + +const SYSTEM_PROMPT: &str = r#"You are a knowledge auditor reviewing candidate paragraphs discovered in a personal knowledge vault. +Your task is to evaluate whether each candidate is worth the user's attention for deeper thinking and knowledge consolidation. + +For each candidate, you receive: +- The candidate paragraph text +- The marking reason (why the system flagged it) +- For similarity pairs: both paragraphs side by side +- For isolated paragraphs: the paragraph alone + +Evaluate based on: +1. Information density: Does this contain a real insight, opinion, or knowledge claim? (vs boilerplate, table of contents, metadata, trivial notes) +2. Actionability: Would reviewing this help the user consolidate, connect, or deepen their understanding? +3. Novelty: For similarity pairs — are they truly expressing the same core idea in different contexts? Or just surface-level keyword overlap? + +Respond with a JSON object containing a "results" array. Each element has: +- "index": the candidate number (1-indexed) +- "verdict": "confirmed" | "downgraded" | "rejected" +- "reason": a concise explanation (50-100 chars) in the same language as the candidate text + +Guidelines for verdict: +- "confirmed": Genuinely valuable — the user should see this and think about it +- "downgraded": Marginally interesting but not urgent — can be shown at lower priority +- "rejected": False positive — template text, boilerplate, meeting notes, or trivially obvious connection + +Guidelines for reason: +- Be specific and actionable +- Good: "这两段从不同角度论证了'约束即自由',合并后可形成更完整的论述" +- Good: "This isolated insight about decision reversibility hasn't been connected to your UX notes" +- Bad: "interesting" or "worth reading" (too vague) +"#; + +fn build_user_prompt(candidates: &[CandidateToConfirm]) -> String { + let mut parts = Vec::new(); + + for (i, c) in candidates.iter().enumerate() { + let idx = i + 1; + match c.marking_reason.as_str() { + "high_similarity" => { + parts.push(format!( + "## Candidate {idx} (similarity pair, score={score:.2})\n\ + ### Paragraph A — {path_a}:\n{text_a}\n\ + ### Paragraph B — {path_b}:\n{text_b}\n", + score = c.similarity_score.unwrap_or(0.0), + path_a = c.rel_path, + text_a = c.excerpt, + path_b = c.paired_rel_path.as_deref().unwrap_or("unknown"), + text_b = c.paired_excerpt.as_deref().unwrap_or("[text unavailable]"), + )); + } + "cross_doc_recurrence" => { + parts.push(format!( + "## Candidate {idx} (recurring theme across {count} docs)\n\ + ### Representative paragraph — {path}:\n{text}\n\ + ### Other related docs: {others}\n", + count = c.cluster_doc_count.unwrap_or(0), + path = c.rel_path, + text = c.excerpt, + others = c.paired_rel_path.as_deref().unwrap_or(""), + )); + } + "semantic_isolated" => { + parts.push(format!( + "## Candidate {idx} (isolated paragraph — no strong connection to other notes)\n\ + ### Source — {path}:\n{text}\n", + path = c.rel_path, + text = c.excerpt, + )); + } + other => { + parts.push(format!( + "## Candidate {idx} (reason: {other})\n\ + ### Source — {path}:\n{text}\n", + path = c.rel_path, + text = c.excerpt, + )); + } + } + } + + format!( + "Please evaluate the following {} candidates:\n\n{}\n\n\ + Respond with JSON: {{\"results\": [{{\"index\": 1, \"verdict\": \"...\", \"reason\": \"...\"}}]}}", + candidates.len(), + parts.join("\n---\n") + ) +} + +// --------------------------------------------------------------------------- +// Core logic +// --------------------------------------------------------------------------- + +/// Load candidate details from DB for confirmation. +pub fn load_candidates_for_confirm( + conn: &Connection, + candidate_ids: &[String], +) -> Result, String> { + let mut result = Vec::with_capacity(candidate_ids.len()); + + for id in candidate_ids { + let row: Result<(String, String, String, Option, Option, String), _> = conn + .query_row( + "SELECT tc.rel_path, tc.marking_reason, tc.chunk_id, + tc.similarity_score, tc.paired_rel_path, tc.id + FROM thought_candidates tc WHERE tc.id = ?1", + params![id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, + ); + + let (rel_path, marking_reason, chunk_id, similarity_score, paired_rel_path, cand_id) = + match row { + Ok(r) => r, + Err(_) => continue, // skip missing candidates + }; + + // Get the excerpt from doc_chunks + let excerpt: String = conn + .query_row( + "SELECT chunk_text FROM doc_chunks WHERE chunk_id = ?1", + params![chunk_id], + |r| r.get(0), + ) + .unwrap_or_default(); + + // For high_similarity, try to get paired excerpt + let paired_excerpt = if marking_reason == "high_similarity" { + if let Some(ref paired_path) = paired_rel_path { + // Find a chunk in the paired doc with similar score + conn.query_row( + "SELECT dc.chunk_text FROM doc_chunks dc + WHERE dc.rel_path = ?1 + LIMIT 1", + params![paired_path], + |r| r.get::<_, String>(0), + ) + .ok() + } else { + None + } + } else { + None + }; + + // For cross_doc_recurrence, count related docs + let cluster_doc_count = if marking_reason == "cross_doc_recurrence" { + paired_rel_path + .as_deref() + .map(|paths| paths.split(',').count() + 1) // +1 for self + } else { + None + }; + + let truncated_excerpt = if excerpt.len() > 500 { + // Find a valid char boundary at or before byte 500 + let mut end = 500; + while end > 0 && !excerpt.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &excerpt[..end]) + } else { + excerpt + }; + + result.push(CandidateToConfirm { + id: cand_id, + rel_path, + excerpt: truncated_excerpt, + marking_reason, + similarity_score, + paired_rel_path, + paired_excerpt, + cluster_doc_count, + }); + } + + Ok(result) +} + +/// Call LLM to confirm a batch of candidates. Returns results for each candidate. +pub async fn confirm_batch_with_llm( + candidates: &[CandidateToConfirm], + workspace_root: &std::path::Path, + http_client: &Arc, +) -> Result, String> { + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Load AI config + let root = workspace_root.to_path_buf(); + let ai = tauri::async_runtime::spawn_blocking(move || { + vault_config::load_ai_config_internal(&root) + }) + .await + .map_err(|e| format!("spawn_blocking join: {e}"))??; + + let provider = create_provider(&ai, None, http_client)?; + + let msgs = vec![ + LlmChatMessage { + role: "system".into(), + content: SYSTEM_PROMPT.into(), + ..Default::default() + }, + LlmChatMessage { + role: "user".into(), + content: build_user_prompt(candidates), + ..Default::default() + }, + ]; + + let overrides = CompletionOverrides { + temperature: Some(0.3), + json_mode: true, + ..Default::default() + }; + + let raw = provider.chat_completion(&msgs, Some(&overrides)).await?; + + // Parse JSON response + let parsed = parse_llm_response(&raw, candidates)?; + + Ok(parsed) +} + +/// Parse the LLM response JSON and map back to candidate IDs. +fn parse_llm_response( + raw: &str, + candidates: &[CandidateToConfirm], +) -> Result, String> { + // Extract JSON object from possible markdown fencing + let s = raw.trim(); + let start = s.find('{').ok_or("No JSON object in LLM response")?; + let end = s.rfind('}').ok_or("No closing brace in LLM response")?; + if end < start { + return Err("Invalid JSON structure".into()); + } + let json_slice = &s[start..=end]; + + let resp: LlmConfirmResponse = + serde_json::from_str(json_slice).map_err(|e| format!("JSON parse error: {e}"))?; + + let mut results = Vec::new(); + for v in resp.results { + // index is 1-based + let idx = v.index.saturating_sub(1); + if idx >= candidates.len() { + continue; + } + let verdict = match v.verdict.as_str() { + "confirmed" | "downgraded" | "rejected" => v.verdict.clone(), + _ => "downgraded".to_string(), // default unknown verdicts to downgraded + }; + results.push(ConfirmResult { + candidate_id: candidates[idx].id.clone(), + verdict, + reason: v.reason, + }); + } + + Ok(results) +} + +/// Write confirmation results back to the database. +pub fn persist_confirm_results( + conn: &Connection, + results: &[ConfirmResult], +) -> Result<(), String> { + let now = Utc::now().to_rfc3339(); + for r in results { + conn.execute( + "UPDATE thought_candidates + SET llm_confirmed = ?1, llm_reason = ?2, llm_confirmed_at = ?3 + WHERE id = ?4", + params![r.verdict, r.reason, now, r.candidate_id], + ) + .map_err(|e| format!("persist confirm result for {}: {e}", r.candidate_id))?; + } + Ok(()) +} + +/// Check how many confirmations have been done today (for daily cap). +pub fn today_confirm_count(conn: &Connection) -> Result { + let today = Utc::now().format("%Y-%m-%d").to_string(); + let count: usize = conn + .query_row( + "SELECT COUNT(*) FROM thought_candidates + WHERE llm_confirmed_at IS NOT NULL AND llm_confirmed_at LIKE ?1", + params![format!("{today}%")], + |r| r.get(0), + ) + .map_err(|e| format!("count today confirms: {e}"))?; + Ok(count) +} + +/// Filter candidate IDs to only those that need (re-)confirmation: +/// - llm_confirmed IS NULL, or +/// - llm_confirmed_at is older than CACHE_VALID_DAYS +pub fn filter_needing_confirmation( + conn: &Connection, + candidate_ids: &[String], +) -> Result, String> { + if candidate_ids.is_empty() { + return Ok(Vec::new()); + } + + let cutoff = (Utc::now() - chrono::Duration::days(CACHE_VALID_DAYS)) + .to_rfc3339(); + + let mut result = Vec::new(); + for id in candidate_ids { + let needs: bool = conn + .query_row( + "SELECT 1 FROM thought_candidates + WHERE id = ?1 + AND (llm_confirmed IS NULL OR llm_confirmed_at < ?2) + AND dismissed_at IS NULL + AND promoted_thought_id IS NULL", + params![id, cutoff], + |_| Ok(true), + ) + .unwrap_or(false); + if needs { + result.push(id.clone()); + } + } + Ok(result) +} + +/// High-level entry point: confirm a batch of candidates (with cap and caching). +/// Returns only the results for candidates that were actually confirmed. +#[allow(dead_code)] +pub async fn confirm_discovery_batch( + conn: &Connection, + candidate_ids: &[String], + workspace_root: &std::path::Path, + http_client: &Arc, +) -> Result, String> { + // Check daily cap + let today_count = today_confirm_count(conn)?; + if today_count >= DAILY_CAP { + return Ok(Vec::new()); // cap reached, silently skip + } + + // Filter to those needing confirmation + let needs_confirm = filter_needing_confirmation(conn, candidate_ids)?; + if needs_confirm.is_empty() { + return Ok(Vec::new()); + } + + // Limit to batch size and remaining daily cap + let remaining_cap = DAILY_CAP - today_count; + let batch_limit = BATCH_SIZE.min(remaining_cap); + let batch_ids: Vec = needs_confirm.into_iter().take(batch_limit).collect(); + + // Load candidate details + let candidates = load_candidates_for_confirm(conn, &batch_ids)?; + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Call LLM + let results = confirm_batch_with_llm(&candidates, workspace_root, http_client).await?; + + // Persist results + persist_confirm_results(conn, &results)?; + + Ok(results) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_llm_response_valid() { + let candidates = vec![ + CandidateToConfirm { + id: "cand-1".into(), + rel_path: "notes/a.md".into(), + excerpt: "Test excerpt A".into(), + marking_reason: "high_similarity".into(), + similarity_score: Some(0.91), + paired_rel_path: Some("notes/b.md".into()), + paired_excerpt: Some("Test excerpt B".into()), + cluster_doc_count: None, + }, + CandidateToConfirm { + id: "cand-2".into(), + rel_path: "notes/c.md".into(), + excerpt: "Isolated thought".into(), + marking_reason: "semantic_isolated".into(), + similarity_score: Some(0.12), + paired_rel_path: None, + paired_excerpt: None, + cluster_doc_count: None, + }, + ]; + + let raw = r#"{"results": [ + {"index": 1, "verdict": "confirmed", "reason": "Both discuss constraint-based design from different angles"}, + {"index": 2, "verdict": "rejected", "reason": "Boilerplate meeting notes, no real insight"} + ]}"#; + + let results = parse_llm_response(raw, &candidates).unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(results[0].candidate_id, "cand-1"); + assert_eq!(results[0].verdict, "confirmed"); + assert_eq!(results[1].candidate_id, "cand-2"); + assert_eq!(results[1].verdict, "rejected"); + } + + #[test] + fn test_parse_llm_response_with_markdown_fencing() { + let candidates = vec![CandidateToConfirm { + id: "cand-1".into(), + rel_path: "x.md".into(), + excerpt: "test".into(), + marking_reason: "semantic_isolated".into(), + similarity_score: None, + paired_rel_path: None, + paired_excerpt: None, + cluster_doc_count: None, + }]; + + let raw = "```json\n{\"results\": [{\"index\": 1, \"verdict\": \"downgraded\", \"reason\": \"Low info density\"}]}\n```"; + let results = parse_llm_response(raw, &candidates).unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].verdict, "downgraded"); + } + + #[test] + fn test_parse_llm_response_invalid_index() { + let candidates = vec![CandidateToConfirm { + id: "cand-1".into(), + rel_path: "x.md".into(), + excerpt: "test".into(), + marking_reason: "semantic_isolated".into(), + similarity_score: None, + paired_rel_path: None, + paired_excerpt: None, + cluster_doc_count: None, + }]; + + // index 99 is out of bounds — should be skipped + let raw = r#"{"results": [{"index": 99, "verdict": "confirmed", "reason": "test"}]}"#; + let results = parse_llm_response(raw, &candidates).unwrap(); + assert_eq!(results.len(), 0); + } + + #[test] + fn test_build_user_prompt_all_types() { + let candidates = vec![ + CandidateToConfirm { + id: "c1".into(), + rel_path: "a.md".into(), + excerpt: "Design systems constrain choices".into(), + marking_reason: "high_similarity".into(), + similarity_score: Some(0.92), + paired_rel_path: Some("b.md".into()), + paired_excerpt: Some("Good component libraries constrain usage".into()), + cluster_doc_count: None, + }, + CandidateToConfirm { + id: "c2".into(), + rel_path: "c.md".into(), + excerpt: "Distributed consensus".into(), + marking_reason: "cross_doc_recurrence".into(), + similarity_score: Some(0.8), + paired_rel_path: Some("d.md,e.md,f.md".into()), + paired_excerpt: None, + cluster_doc_count: Some(4), + }, + CandidateToConfirm { + id: "c3".into(), + rel_path: "g.md".into(), + excerpt: "People avoid irreversible decisions".into(), + marking_reason: "semantic_isolated".into(), + similarity_score: Some(0.15), + paired_rel_path: None, + paired_excerpt: None, + cluster_doc_count: None, + }, + ]; + + let prompt = build_user_prompt(&candidates); + assert!(prompt.contains("Candidate 1 (similarity pair")); + assert!(prompt.contains("Candidate 2 (recurring theme")); + assert!(prompt.contains("Candidate 3 (isolated paragraph")); + assert!(prompt.contains("Design systems constrain")); + assert!(prompt.contains("Good component libraries")); + } +} diff --git a/src-tauri/src/latent_paragraphs.rs b/src-tauri/src/latent_paragraphs.rs index 4258f7a..e480a43 100644 --- a/src-tauri/src/latent_paragraphs.rs +++ b/src-tauri/src/latent_paragraphs.rs @@ -1,5 +1,5 @@ use rusqlite::{params, Connection}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::path::Path; @@ -28,6 +28,44 @@ pub struct CandidateForUi { pub paired_rel_path: Option, pub start_line: i32, pub end_line: i32, + // LLM confirmation (Spec 11) + pub llm_confirmed: Option, + pub llm_reason: Option, +} + +// --------------------------------------------------------------------------- +// Discovery filter/response types (for list_candidates_filtered) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryFilter { + /// "high_similarity" | "cross_doc_recurrence" | "semantic_isolated" | None (all) + pub marking_reason: Option, + /// "freshness" | "similarity" | "age" + pub sort_by: Option, + /// "confirmed" | "downgraded" | "unconfirmed" | None (all visible) + pub llm_status: Option, + pub offset: usize, + pub limit: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryListResponse { + pub items: Vec, + pub total: usize, + pub by_reason: DiscoveryReasonCounts, + /// Count of LLM-confirmed candidates (for filter badge) + pub confirmed_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DiscoveryReasonCounts { + pub high_similarity: usize, + pub cross_doc_recurrence: usize, + pub semantic_isolated: usize, } #[derive(Debug, Clone)] @@ -76,6 +114,31 @@ pub fn init_candidates_schema(conn: &Connection) -> Result<(), String> { "#, ) .map_err(|e| format!("init thought_candidates schema: {e}"))?; + + // Migration: add LLM confirmation columns (Spec 11) + migrate_add_llm_confirm_columns(conn)?; + + Ok(()) +} + +/// Add llm_confirmed, llm_reason, llm_confirmed_at columns if they do not exist. +fn migrate_add_llm_confirm_columns(conn: &Connection) -> Result<(), String> { + // Check if column already exists by querying table_info + let has_col: bool = conn + .prepare("SELECT 1 FROM pragma_table_info('thought_candidates') WHERE name = 'llm_confirmed'") + .and_then(|mut s| s.exists([])) + .unwrap_or(false); + if has_col { + return Ok(()); + } + conn.execute_batch( + r#" + ALTER TABLE thought_candidates ADD COLUMN llm_confirmed TEXT; + ALTER TABLE thought_candidates ADD COLUMN llm_reason TEXT; + ALTER TABLE thought_candidates ADD COLUMN llm_confirmed_at TEXT; + "#, + ) + .map_err(|e| format!("migrate llm_confirm columns: {e}"))?; Ok(()) } @@ -738,12 +801,245 @@ pub fn list_candidates( paired_rel_path: paired, start_line, end_line, + llm_confirmed: None, + llm_reason: None, }); } Ok(result) } +// --------------------------------------------------------------------------- +// Filtered listing for Discovery pane +// --------------------------------------------------------------------------- + +fn count_by_reason(conn: &Connection) -> Result { + let mut stmt = conn + .prepare( + "SELECT marking_reason, COUNT(*) FROM thought_candidates + WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL + AND (llm_confirmed IS NULL OR llm_confirmed != 'rejected') + GROUP BY marking_reason", + ) + .map_err(|e| format!("prepare count_by_reason: {e}"))?; + + let mut counts = DiscoveryReasonCounts { + high_similarity: 0, + cross_doc_recurrence: 0, + semantic_isolated: 0, + }; + let rows = stmt + .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))) + .map_err(|e| format!("count_by_reason query: {e}"))?; + + for row in rows { + let (reason, cnt) = row.map_err(|e| format!("count_by_reason row: {e}"))?; + match reason.as_str() { + "high_similarity" => counts.high_similarity = cnt, + "cross_doc_recurrence" => counts.cross_doc_recurrence = cnt, + "semantic_isolated" => counts.semantic_isolated = cnt, + _ => {} + } + } + Ok(counts) +} + +/// List candidates with filtering, sorting, and pagination for the Discovery pane. +/// `workspace_root` is used for freshness sort (stat file mtime). +pub fn list_candidates_filtered( + conn: &Connection, + filter: &DiscoveryFilter, + workspace_root: &Path, +) -> Result { + let by_reason = count_by_reason(conn)?; + + // Build WHERE clause + let mut where_clauses = vec![ + "tc.dismissed_at IS NULL".to_string(), + "tc.promoted_thought_id IS NULL".to_string(), + // Spec 11: exclude LLM-rejected candidates by default + "(tc.llm_confirmed IS NULL OR tc.llm_confirmed != 'rejected')".to_string(), + ]; + let mut params_vec: Vec> = Vec::new(); + + if let Some(ref reason) = filter.marking_reason { + where_clauses.push(format!("tc.marking_reason = ?{}", params_vec.len() + 1)); + params_vec.push(Box::new(reason.clone())); + } + + // Spec 11: filter by LLM confirmation status + if let Some(ref llm_status) = filter.llm_status { + match llm_status.as_str() { + "confirmed" => { + where_clauses.push("tc.llm_confirmed = 'confirmed'".to_string()); + } + "downgraded" => { + where_clauses.push("tc.llm_confirmed = 'downgraded'".to_string()); + } + "unconfirmed" => { + where_clauses.push("tc.llm_confirmed IS NULL".to_string()); + } + _ => {} // unknown value, no additional filter + } + } + + let where_sql = where_clauses.join(" AND "); + + // Count total matching + let count_sql = format!( + "SELECT COUNT(*) FROM thought_candidates tc WHERE {where_sql}" + ); + let total: usize = { + let mut stmt = conn.prepare(&count_sql).map_err(|e| format!("prepare count: {e}"))?; + let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); + stmt.query_row(params_refs.as_slice(), |r| r.get(0)) + .map_err(|e| format!("count query: {e}"))? + }; + + // Build ORDER BY — Spec 11: confirmed first, then unconfirmed, then downgraded + let order_sql = match filter.sort_by.as_deref() { + Some("similarity") => "CASE WHEN tc.llm_confirmed = 'confirmed' THEN 0 WHEN tc.llm_confirmed IS NULL THEN 1 ELSE 2 END ASC, tc.similarity_score DESC", + Some("age") => "CASE WHEN tc.llm_confirmed = 'confirmed' THEN 0 WHEN tc.llm_confirmed IS NULL THEN 1 ELSE 2 END ASC, tc.created_at ASC", + _ => "CASE WHEN tc.llm_confirmed = 'confirmed' THEN 0 WHEN tc.llm_confirmed IS NULL THEN 1 ELSE 2 END ASC, tc.similarity_score DESC", + }; + + // Query with LIMIT/OFFSET + let query_sql = format!( + "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, + tc.llm_confirmed, tc.llm_reason + FROM thought_candidates tc + WHERE {where_sql} + ORDER BY {order_sql} + LIMIT ?{} OFFSET ?{}", + params_vec.len() + 1, + params_vec.len() + 2, + ); + params_vec.push(Box::new(filter.limit as i64)); + params_vec.push(Box::new(filter.offset as i64)); + + let mut stmt = conn.prepare(&query_sql).map_err(|e| format!("prepare filtered list: {e}"))?; + let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect(); + + let rows = stmt + .query_map(params_refs.as_slice(), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i32>(2)?, + row.get::<_, i32>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, Option>(6)?, + row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, + )) + }) + .map_err(|e| format!("query filtered candidates: {e}"))?; + + let mut items = Vec::new(); + for row in rows { + let (id, rel_path, start_line, end_line, reason, score, paired, chunk_id, llm_confirmed, llm_reason) = + row.map_err(|e| format!("read filtered 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(); + + items.push(CandidateForUi { + id, + rel_path, + excerpt: excerpt(&chunk_text), + marking_reason: reason, + similarity_score: score, + paired_rel_path: paired, + start_line, + end_line, + llm_confirmed, + llm_reason, + }); + } + + // For freshness sort: re-sort by file mtime (Phase 1 simple approach) + // Preserve LLM confirmation priority: confirmed first, then by mtime + if filter.sort_by.as_deref() == Some("freshness") || filter.sort_by.is_none() { + sort_by_freshness(&mut items, workspace_root); + // Stable partition: confirmed first, then unconfirmed, then downgraded + items.sort_by_key(|item| match item.llm_confirmed.as_deref() { + Some("confirmed") => 0, + None => 1, + Some("downgraded") => 2, + _ => 3, + }); + } + + // Count confirmed candidates (for filter badge in UI) + let confirmed_count: usize = conn + .query_row( + "SELECT COUNT(*) FROM thought_candidates + WHERE dismissed_at IS NULL AND promoted_thought_id IS NULL + AND llm_confirmed = 'confirmed'", + [], + |r| r.get(0), + ) + .unwrap_or(0); + + Ok(DiscoveryListResponse { + items, + total, + by_reason, + confirmed_count, + }) +} + +/// Sort items by file modification time (most recent first). +/// Files that cannot be stat'd sort to the end. +fn sort_by_freshness(items: &mut Vec, workspace_root: &Path) { + let mut mtime_cache: HashMap> = HashMap::new(); + let get_mtime = |path: &str, cache: &mut HashMap>| -> Option { + if let Some(cached) = cache.get(path) { + return *cached; + } + let full = workspace_root.join(path); + let mt = std::fs::metadata(&full).ok().and_then(|m| m.modified().ok()); + cache.insert(path.to_string(), mt); + mt + }; + + items.sort_by(|a, b| { + let ma = get_mtime(&a.rel_path, &mut mtime_cache); + let mb = get_mtime(&b.rel_path, &mut mtime_cache); + mb.cmp(&ma) // descending: most recent first + }); +} + +/// Batch dismiss multiple candidates at once. +pub fn batch_dismiss(conn: &Connection, ids: &[String]) -> Result { + if ids.is_empty() { + return Ok(0); + } + let now = chrono::Utc::now().to_rfc3339(); + let placeholders: String = ids.iter().map(|_| "?").collect::>().join(","); + let sql = format!( + "UPDATE thought_candidates SET dismissed_at = ?1 WHERE id IN ({placeholders}) AND dismissed_at IS NULL" + ); + let mut param_values: Vec> = Vec::with_capacity(ids.len() + 1); + param_values.push(Box::new(now)); + for id in ids { + param_values.push(Box::new(id.clone())); + } + let params_refs: Vec<&dyn rusqlite::ToSql> = param_values.iter().map(|p| p.as_ref()).collect(); + let affected = conn + .execute(&sql, params_refs.as_slice()) + .map_err(|e| format!("batch dismiss: {e}"))?; + Ok(affected) +} + pub fn dismiss_candidate(conn: &Connection, id: &str) -> Result<(), String> { let now = chrono::Utc::now().to_rfc3339(); conn.execute( @@ -795,6 +1091,8 @@ pub fn get_candidate_chunk_text( paired_rel_path: paired, start_line, end_line, + llm_confirmed: None, + llm_reason: None, }; Ok((chunk_text, candidate)) } @@ -843,6 +1141,94 @@ pub fn promote_candidate( Ok(resp.thought_id) } +/// Batch promote multiple candidates to thoughts. +/// Groups by file and processes within each file in reverse line order +/// to avoid line-number offset issues from earlier insertions. +/// Returns the list of newly created thought IDs. +pub fn batch_promote( + embed_conn: &Connection, + canonical_root: &std::path::Path, + candidate_ids: &[String], +) -> Result, String> { + if candidate_ids.is_empty() { + return Ok(vec![]); + } + if candidate_ids.len() > 50 { + return Err("batch promote limit is 50 candidates".to_string()); + } + + // Gather candidate metadata to group and sort by file + start_line DESC + let mut candidates: Vec<(String, String, i32)> = Vec::with_capacity(candidate_ids.len()); + for id in candidate_ids { + let (_, info) = get_candidate_chunk_text(embed_conn, id)?; + candidates.push((id.clone(), info.rel_path, info.start_line)); + } + + // Sort by (rel_path ASC, start_line DESC) — within same file, process bottom-first + candidates.sort_by(|a, b| { + a.1.cmp(&b.1).then(b.2.cmp(&a.2)) + }); + + let mut created_ids = Vec::with_capacity(candidate_ids.len()); + for (cid, _, _) in &candidates { + match promote_candidate(embed_conn, canonical_root, cid) { + Ok(thought_id) => created_ids.push(thought_id), + Err(e) => { + // Log but continue — partial success is acceptable + eprintln!("batch_promote: skipping candidate {cid}: {e}"); + } + } + } + Ok(created_ids) +} + +/// Return 1-2 "daily picks" from the candidate pool for the review completion screen. +/// Strategy: rotate across marking reasons (round-robin by day-of-year), pick freshest within. +pub fn list_daily_picks( + conn: &Connection, + workspace_root: &Path, +) -> Result, String> { + // Use day-of-year to rotate which reason goes first + use chrono::Datelike; + let day_of_year = chrono::Utc::now().ordinal() as usize; + let reasons = ["high_similarity", "cross_doc_recurrence", "semantic_isolated"]; + let primary_reason = reasons[day_of_year % reasons.len()]; + + // Try to get 1 from primary reason, 1 from any other + let mut picks = Vec::with_capacity(2); + + let primary_filter = DiscoveryFilter { + marking_reason: Some(primary_reason.to_string()), + sort_by: Some("freshness".to_string()), + llm_status: Some("confirmed".to_string()), + offset: 0, + limit: 1, + }; + if let Ok(resp) = list_candidates_filtered(conn, &primary_filter, workspace_root) { + picks.extend(resp.items); + } + + // Get 1 more from any category (different from what we already picked) + let exclude_id = picks.first().map(|p| p.id.clone()); + let all_filter = DiscoveryFilter { + marking_reason: None, + sort_by: Some("similarity".to_string()), + llm_status: None, + offset: 0, + limit: 3, + }; + if let Ok(resp) = list_candidates_filtered(conn, &all_filter, workspace_root) { + for item in resp.items { + if Some(&item.id) != exclude_id.as_ref() { + picks.push(item); + break; + } + } + } + + Ok(picks) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd1efd5..1cf7811 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,7 @@ mod semantic_index; mod workspace_text_search; mod understanding_graph; mod latent_paragraphs; +mod discovery_confirm; mod link_recommendation; mod topic_network; mod tools; @@ -1995,6 +1996,105 @@ async fn dismiss_latent_candidate( .map_err(|e| e.to_string())? } +#[tauri::command] +async fn list_discovery_candidates( + state: tauri::State<'_, WorkspaceState>, + filter: latent_paragraphs::DiscoveryFilter, +) -> Result { + let root = lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::list_candidates_filtered(&conn, &filter, &root) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn confirm_discovery_batch( + state: tauri::State<'_, WorkspaceState>, + http_client: tauri::State<'_, Arc>, + candidate_ids: Vec, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + let root2 = root.clone(); + let client = Arc::clone(http_client.inner()); + + // Load candidates and check caps in blocking context + let candidates = tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root2)?; + let today_count = discovery_confirm::today_confirm_count(&conn)?; + if today_count >= 30 { + return Ok(Vec::new()); + } + let needs = discovery_confirm::filter_needing_confirmation(&conn, &candidate_ids)?; + let batch: Vec = needs.into_iter().take(5).collect(); + discovery_confirm::load_candidates_for_confirm(&conn, &batch) + }) + .await + .map_err(|e| e.to_string())??; + + if candidates.is_empty() { + return Ok(Vec::new()); + } + + // Call LLM (async) + let results = discovery_confirm::confirm_batch_with_llm(&candidates, &root, &client).await?; + + // Persist results + let results_for_persist = results.clone(); + let root3 = root.clone(); + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root3)?; + discovery_confirm::persist_confirm_results(&conn, &results_for_persist) + }) + .await + .map_err(|e| e.to_string())??; + + Ok(results) +} + +#[tauri::command] +async fn batch_dismiss_candidates( + state: tauri::State<'_, WorkspaceState>, + candidate_ids: Vec, +) -> Result { + let root = lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::batch_dismiss(&conn, &candidate_ids) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn batch_promote_candidates( + state: tauri::State<'_, WorkspaceState>, + candidate_ids: Vec, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::batch_promote(&conn, &root, &candidate_ids) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +async fn list_discovery_daily_picks( + state: tauri::State<'_, WorkspaceState>, +) -> Result, String> { + let root = lock_workspace_root(&state)?; + tauri::async_runtime::spawn_blocking(move || { + let conn = semantic_index::open_embedding_db(&root)?; + latent_paragraphs::list_daily_picks(&conn, &root) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] async fn check_cognitive_push_now( state: tauri::State<'_, WorkspaceState>, @@ -2137,6 +2237,11 @@ pub fn run() { skills::commands::list_available_tools, onboarding::seed_onboarding_content, list_latent_candidates, + list_discovery_candidates, + confirm_discovery_batch, + batch_dismiss_candidates, + batch_promote_candidates, + list_discovery_daily_picks, trigger_latent_scan, promote_candidate_to_thought, dismiss_latent_candidate diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2767aaf..d7cd96a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Knowforge", - "version": "0.7.5", + "version": "0.7.6", "identifier": "com.knowforge.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 89be8ac..e23af17 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -12,7 +12,7 @@ "backgroundColor": "#e8e8e8", "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { "x": 14, "y": 15 } + "trafficLightPosition": { "x": 14, "y": 18 } } ] } diff --git a/src/App.css b/src/App.css index 7351d77..2b514b3 100644 --- a/src/App.css +++ b/src/App.css @@ -259,6 +259,23 @@ body { border-radius: 1px; } +.activity-bar__badge { + position: absolute; + top: 3px; + right: 3px; + min-width: 14px; + height: 14px; + padding: 0 3px; + font-size: 9px; + font-weight: 600; + line-height: 14px; + text-align: center; + color: #fff; + background: #e53935; + border-radius: 7px; + pointer-events: none; +} + @media (prefers-color-scheme: dark) { .activity-bar { border-right-color: rgba(255, 255, 255, 0.08); @@ -579,7 +596,7 @@ body { /* macOS Overlay:交通灯浮在 WebView 上方左侧,固定留白避免重叠 */ .layout--platform-mac .app-top-toolbar__start { - padding-left: 74px; + padding-left: 82px; gap: calc(8px * var(--tool-strip-scale)); } diff --git a/src/App.tsx b/src/App.tsx index 8eb5d0a..83a3ae4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,13 +2,12 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { ask } from "@tauri-apps/plugin-dialog"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import GithubSlugger from "github-slugger"; + import { lazy, Suspense, useCallback, useEffect, - useLayoutEffect, useMemo, useRef, useState, @@ -16,36 +15,27 @@ import { } from "react"; import { useTranslation } from "react-i18next"; import { AiNoteContextProvider } from "./contexts/AiNoteContext"; -import { AiConversationSessionProvider } from "./contexts/AiConversationSessionContext"; -import { ThoughtMgmtAiConversationSessionProvider } from "./contexts/ThoughtMgmtAiConversationSessionContext"; import type { DepthMode } from "./types/cognitiveTypes"; -import { AiConversationPanel } from "./components/AiConversationPanel"; -import { AiConversationToolbar } from "./components/AiConversationToolbar"; -const CrepeMarkdownEditor = lazy(() => import("./components/CrepeMarkdownEditor")); const AiLlmSettingsModal = lazy(() => import("./components/AiLlmSettingsModal")); -import { EditorTabBar, MARKDOWN_TAB_PANEL_ID, editorTabDomId } from "./components/EditorTabBar"; + import { FileTree, collectKfPrivateRelPaths } from "./components/FileTree"; -import { KfPrivateLockIcon } from "./components/KfPrivateLockIcon"; -import { OutlineBulkToolbar } from "./components/OutlineBulkToolbar"; -import { OutlinePanel } from "./components/OutlinePanel"; import type { CrepeMarkdownEditorApi } from "./components/CrepeMarkdownEditor"; import { CognitiveReportPanel } from "./components/cognitive-report/CognitiveReportPanel"; import { CommandPalette } from "./components/CommandPalette"; -import { EditorThoughtsPanel } from "./components/EditorThoughtsPanel"; -import { EditorWritingCoachHost, type EditorWritingCoachHostHandle } from "./components/EditorWritingCoachHost"; -import { RightPanelReviewTab } from "./components/RightPanelReviewTab"; -import { LinkRecommendationPanel } from "./components/LinkRecommendationPanel"; -import { RightPanelShell, type RightPanelTab } from "./components/RightPanelShell"; +import { AppTopToolbar } from "./components/AppTopToolbar"; +import { ContentArea } from "./components/ContentArea"; +import { type EditorWritingCoachHostHandle } from "./components/EditorWritingCoachHost"; +import { type RightPanelTab } from "./components/RightPanelShell"; import { ThoughtMaturityToastHost } from "./components/ThoughtMaturityToastHost"; -import { ThoughtManagementPanel } from "./components/ThoughtManagementPanel"; 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"; + +import { useGlobalShortcuts } from "./hooks/useGlobalShortcuts"; +import { useHeadingNavigation } from "./hooks/useHeadingNavigation"; +import { useWindowLifecycle } from "./hooks/useWindowLifecycle"; import { useKfPrivateForPath } from "./hooks/useKfPrivateForPath"; import { useOpenDocs } from "./hooks/useOpenDocs"; import { useOutline } from "./hooks/useOutline"; @@ -67,41 +57,7 @@ import "./App.css"; /** 与会话恢复配套:须与 knowforge:lastWorkspace 指向同一工作区根路径 */ const LAST_SESSION_KEY = "knowforge:lastSession"; -/** Wikilink `#` 标题定位:等 ProseMirror 挂载的最长等待(毫秒);弱设备/大文档下避免无限 rAF */ -const WIKI_HEADING_NAV_RETRY_BUDGET_MS = 2500; - -const PM_HEADING_SELECTOR = - ".ProseMirror h1, .ProseMirror h2, .ProseMirror h3, .ProseMirror h4, .ProseMirror h5, .ProseMirror h6"; - -/** 在 Milkdown 滚动容器内按 GitHub slug 查找标题 DOM(与 extractOutline / navigateToHeading 一致) */ -function findProseMirrorHeadingBySlug( - scrollEl: HTMLElement | null, - slug: string, -): HTMLElement | null { - if (!scrollEl) { - return null; - } - const slugger = new GithubSlugger(); - const headings = scrollEl.querySelectorAll(PM_HEADING_SELECTOR); - for (const heading of headings) { - if (!(heading instanceof HTMLElement)) { - continue; - } - const text = heading.textContent?.trim() ?? ""; - if (slugger.slug(text) === slug) { - return heading; - } - } - return null; -} -function scrollMilkdownHeadingIntoView(scrollEl: HTMLElement, headingEl: HTMLElement) { - const pad = 12; - const cRect = scrollEl.getBoundingClientRect(); - const eRect = headingEl.getBoundingClientRect(); - const top = scrollEl.scrollTop + (eRect.top - cRect.top) - pad; - scrollEl.scrollTo({ top: Math.max(0, top), behavior: "smooth" }); -} /** 与 src-tauri 中 rel_path_components_ok 规则一致,禁止空段、.、.. */ function isValidStoredRelPath(relPath: string): boolean { @@ -203,8 +159,7 @@ function App() { const anyDragging = leftResizable.isDragging || rightResizable.isDragging; const editorScrollRef = useRef(null); - /** 递增代数:新一次 wikilink 标题定位或卸载时作废仍在排队的 rAF */ - const wikiHeadingNavRetryGenerationRef = useRef(0); + const rawSourceTextareaRef = useRef(null); const crepeEditorApiRef = useRef(null); const writingCoachRef = useRef(null); @@ -213,8 +168,7 @@ function App() { const docState = useOpenDocs(workspaceReady); const activePathSwitchTraceRef = useRef(null); const markdownBodyCacheRef = useRef>(new Map()); - const flushDirtyBeforeExitRef = useRef(docState.flushDirtyDocumentsBeforeExit); - flushDirtyBeforeExitRef.current = docState.flushDirtyDocumentsBeforeExit; + const openOrFocusRef = useRef(docState.openOrFocusTab); openOrFocusRef.current = docState.openOrFocusTab; @@ -281,38 +235,9 @@ function App() { workspaceRoot: rootPath, }); - /** - * 离开「回顾」标签后本会话内不再显示角标(换工作区重置)。 - * 必须在渲染阶段同步置位:若仅用 useEffect 在 tab 已非 review 后才 setState, - * 会多出一帧 dismissed 仍为 false,角标会按 totalDue 闪一下。 - */ - const reviewBadgeAfterVisitSuppressedRef = useRef(false); - const lastRootPathForReviewBadgeRef = useRef(rootPath); - if (lastRootPathForReviewBadgeRef.current !== rootPath) { - reviewBadgeAfterVisitSuppressedRef.current = false; - lastRootPathForReviewBadgeRef.current = rootPath; - } - - const prevRightPanelTabForBadgeRef = useRef(rightPanelTab); - if (prevRightPanelTabForBadgeRef.current === "review" && rightPanelTab !== "review") { - reviewBadgeAfterVisitSuppressedRef.current = true; - } - - useLayoutEffect(() => { - prevRightPanelTabForBadgeRef.current = rightPanelTab; - }, [rightPanelTab]); - - const reviewTabBadgeCount = useMemo(() => { - if (reviewBadgeAfterVisitSuppressedRef.current || rightPanelTab === "review" || reviewDueTabCount <= 0) { - return null; - } - return reviewDueTabCount; - }, [reviewDueTabCount, rightPanelTab, rootPath]); - - const requestOpenChallengeReview = useCallback(() => { - setLeftPanelView("files"); - setRightPanelOpen(true); - setRightPanelTab("review"); + /** Navigate to Practice Mode (used by keyboard shortcut and onboarding) */ + const requestOpenPracticeMode = useCallback(() => { + setLeftPanelView("practice"); }, []); useEffect(() => { @@ -337,15 +262,32 @@ function App() { docState, activePath: docState.activePath, tauriRuntime, - onStartChallengeReview: requestOpenChallengeReview, + onStartChallengeReview: requestOpenPracticeMode, }); resetWorkspaceFileCommandsRef.current = resetWorkspaceFileCommands; useEffect(() => { const onOpenAiSettings = () => setAiSettingsOpen(true); + const onGoToPractice = () => setLeftPanelView("practice"); + const onGoToFiles = () => setLeftPanelView("files"); + const onOpenNoteInEditor = (e: Event) => { + const relPath = (e as CustomEvent<{ relPath: string }>).detail?.relPath; + if (relPath) { + setLeftPanelView("files"); + void openOrFocusRef.current(relPath); + } + }; window.addEventListener(OPEN_AI_SETTINGS_EVENT, onOpenAiSettings); - return () => window.removeEventListener(OPEN_AI_SETTINGS_EVENT, onOpenAiSettings); + window.addEventListener("knowforge:goToPractice", onGoToPractice); + window.addEventListener("knowforge:goToFiles", onGoToFiles); + window.addEventListener("knowforge:openNoteInEditor", onOpenNoteInEditor); + return () => { + window.removeEventListener(OPEN_AI_SETTINGS_EVENT, onOpenAiSettings); + window.removeEventListener("knowforge:goToPractice", onGoToPractice); + window.removeEventListener("knowforge:goToFiles", onGoToFiles); + window.removeEventListener("knowforge:openNoteInEditor", onOpenNoteInEditor); + }; }, []); // 工作区就绪后从 vault config 读取持久化的 depthMode @@ -465,16 +407,7 @@ function App() { void invoke("sync_open_markdown_watchers", { relPaths: docState.tabPaths }).catch(() => {}); }, [workspaceReady, docState.tabPaths]); - /** 浏览器预览:关闭页面前提示未保存 */ - useEffect(() => { - const onBeforeUnload = (e: BeforeUnloadEvent) => { - if (docState.hasAnyDirtyTab()) { - e.preventDefault(); - } - }; - window.addEventListener("beforeunload", onBeforeUnload); - return () => window.removeEventListener("beforeunload", onBeforeUnload); - }, [docState.hasAnyDirtyTab]); + useEffect(() => { /** 切换活动文档或标签时默认回到 Markdown 预览 */ @@ -656,80 +589,15 @@ function App() { const editorUsable = !!docState.activePath && !loadingDoc && !loadError && !!current; - const workspaceReadyForShortcutRef = useRef(workspaceReady); - const editorUsableForShortcutRef = useRef(editorUsable); - workspaceReadyForShortcutRef.current = workspaceReady; - editorUsableForShortcutRef.current = editorUsable; - - /** - * 全局快捷键:单一 window keydown,避免多段 useEffect 在依赖抖动或 StrictMode 下重复注册; - * ⌘F 条件用 ref 读最新 workspace/editor 状态,监听本身空依赖只挂载一次。 - */ - useEffect(() => { - const inEditableField = (t: EventTarget | null) => - t instanceof HTMLElement && t.closest("input, textarea, select, [contenteditable='true']"); - - const onKey = (e: KeyboardEvent) => { - const mod = e.metaKey || e.ctrlKey; - if (!mod) { - return; - } - - // ⌘L / Ctrl+L:打开侧栏并切到 AI(输入框内不触发) - if (!e.shiftKey && (e.key === "l" || e.key === "L")) { - if (inEditableField(e.target)) { - return; - } - e.preventDefault(); - setRightPanelOpen(true); - setRightPanelTab("ai"); - return; - } - - // ⌘⇧P / Ctrl+Shift+P:命令面板(输入框内不触发) - if (e.shiftKey && (e.key === "p" || e.key === "P")) { - if (inEditableField(e.target)) { - return; - } - e.preventDefault(); - setCognitiveReportOpen(false); - setCommandPaletteOpen((o) => !o); - return; - } - - // ⌘⇧W / Ctrl+Shift+W:手动触发写作教练(编辑器内也需响应) - if (e.shiftKey && (e.key === "w" || e.key === "W")) { - if (!editorUsableForShortcutRef.current) { - return; - } - e.preventDefault(); - writingCoachRef.current?.triggerManually(); - return; - } - - // ⌘F / Ctrl+F:篇内查找(焦点在正文或原文区时) - if (!e.shiftKey && (e.key === "f" || e.key === "F")) { - const el = e.target; - if (!(el instanceof HTMLElement)) { - return; - } - if (el.closest("[data-editor-find-input]")) { - return; - } - if (!workspaceReadyForShortcutRef.current || !editorUsableForShortcutRef.current) { - return; - } - const inDoc = el.closest("[data-milkdown-root], .main__raw-doc-source, .editor-scroll__body"); - if (!inDoc) { - return; - } - e.preventDefault(); - setEditorFindOpen(true); - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, []); + useGlobalShortcuts( + { + openAiPanel: () => { setRightPanelOpen(true); setRightPanelTab("ai"); }, + toggleCommandPalette: () => { setCognitiveReportOpen(false); setCommandPaletteOpen((o) => !o); }, + triggerWritingCoach: () => { writingCoachRef.current?.triggerManually(); }, + openEditorFind: () => { setEditorFindOpen(true); }, + }, + { workspaceReady, editorUsable }, + ); const saveDisabled = !workspaceReady || !editorUsable || !docState.dirty || docState.saving; @@ -773,47 +641,7 @@ function App() { refreshTree, ]); - const navigateToHeading = useCallback((slug: string) => { - requestAnimationFrame(() => { - const outer = editorScrollRef.current; - const scrollEl = outer?.querySelector("[data-milkdown-root]") as HTMLElement | null; - const el = findProseMirrorHeadingBySlug(scrollEl, slug); - if (!(el instanceof HTMLElement) || !scrollEl) { - return; - } - scrollMilkdownHeadingIntoView(scrollEl, el); - }); - }, []); - - /** 换文注入后再定位标题(wikilink 带 # 片段) */ - const navigateToHeadingWithRetry = useCallback((slug: string) => { - const myGen = (wikiHeadingNavRetryGenerationRef.current += 1); - const t0 = performance.now(); - - const step = () => { - if (wikiHeadingNavRetryGenerationRef.current !== myGen) { - return; - } - if (performance.now() - t0 > WIKI_HEADING_NAV_RETRY_BUDGET_MS) { - return; - } - const outer = editorScrollRef.current; - const scrollEl = outer?.querySelector("[data-milkdown-root]") as HTMLElement | null; - const el = findProseMirrorHeadingBySlug(scrollEl, slug); - if (el && scrollEl) { - scrollMilkdownHeadingIntoView(scrollEl, el); - return; - } - requestAnimationFrame(step); - }; - requestAnimationFrame(step); - }, []); - - useEffect(() => { - return () => { - wikiHeadingNavRetryGenerationRef.current += 1; - }; - }, []); + const { navigateToHeading, navigateToHeadingWithRetry } = useHeadingNavigation(editorScrollRef); const onOpenCoachMarkdownPath = useCallback( async (relPath: string, meta?: { headingFragment?: string | null }) => { @@ -832,6 +660,25 @@ function App() { [docState.openOrFocusTab, getCachedMarkdownBodyForEditor, navigateToHeadingWithRetry], ); + const handleToolbarTabSelect = useCallback((p: string) => { + if (p === docState.activePath && leftPanelView === "files") { + return; + } + void changeView("files").then((ok) => { + if (!ok) return; + logPerfMark("markdown.tab_switch.select", { + from: docState.activePath, + to: p, + }); + activePathSwitchTraceRef.current = startPerfTrace("markdown.tab_switch.to_next_frame", { + from: docState.activePath, + to: p, + }); + docState.setSaveError(null); + docState.setActivePath(p); + }); + }, [docState.activePath, leftPanelView, changeView, docState.setSaveError, docState.setActivePath]); + const openCognitiveReportFromPalette = useCallback(() => { setCommandPaletteOpen(false); setCognitiveReportOpen(true); @@ -846,56 +693,12 @@ function App() { [tauriRuntime], ); - /** 关闭窗口前刷盘;磁盘冲突或未写入失败时拦截或二次确认 */ - useEffect(() => { - if (!tauriRuntime || !appWindow) { - return; - } - let cancelled = false; - let unlisten: (() => void) | undefined; - void appWindow - .onCloseRequested(async (event) => { - event.preventDefault(); - try { - const { conflictDirtyPaths, saveFailed } = await flushDirtyBeforeExitRef.current(); - if (saveFailed) { - return; - } - if (conflictDirtyPaths.length > 0) { - const ok = await ask( - t("dialogs.closeWindowDiskConflict", { count: conflictDirtyPaths.length }), - { - title: t("dialogs.close"), - kind: "warning", - }, - ); - if (!ok) { - return; - } - } - await appWindow.destroy(); - } catch (e) { - // 已 preventDefault:异常时必须尽力 destroy,否则窗口永远无法关闭 - console.error(e); - try { - await appWindow.destroy(); - } catch (e2) { - console.error(e2); - } - } - }) - .then((fn) => { - if (cancelled) { - fn(); - return; - } - unlisten = fn; - }); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [appWindow, tauriRuntime, t]); + useWindowLifecycle({ + tauriRuntime, + appWindow, + flushDirtyBeforeExit: docState.flushDirtyDocumentsBeforeExit, + hasAnyDirtyTab: docState.hasAnyDirtyTab, + }); const isMacPlatform = /Mac/i.test(navigator.userAgent); const isWindowsPlatform = /Windows/i.test(navigator.userAgent); @@ -935,98 +738,7 @@ function App() { [appWindow], ); - const renderWindowControls = (placement: "leading" | "trailing") => { - if (!tauriRuntime) { - return null; - } - const renderMacControls = isMacPlatform && placement === "leading"; - const renderDesktopControls = !isMacPlatform && placement === "trailing"; - if (!renderMacControls && !renderDesktopControls) { - return null; - } - - /* macOS:系统交通灯在标题栏内(tauri.macos.conf.json:Transparent + decorations) */ - if (renderMacControls) { - return null; - } - - return ( -
- - - -
- ); - }; return ( ) : null} -
-
- {renderWindowControls("leading")} - - {tauriRuntime && workspaceReady ? ( - - ) : null} -
-
-
- { - if (p === docState.activePath && leftPanelView === "files") { - return; - } - void changeView("files").then((ok) => { - if (!ok) return; - logPerfMark("markdown.tab_switch.select", { - from: docState.activePath, - to: p, - }); - activePathSwitchTraceRef.current = startPerfTrace("markdown.tab_switch.to_next_frame", { - from: docState.activePath, - to: p, - }); - docState.setSaveError(null); - docState.setActivePath(p); - }); - }} - onClose={(p) => void docState.closeTab(p)} - onCloseAll={() => void docState.closeAllTabs()} - /> -
-
- {tauriRuntime && workspaceReady && editorUsable && docState.saveFeedback !== "idle" ? ( - - {docState.saveFeedback === "pending_auto" - ? t("toolbar.autoSavePending") - : docState.saveFeedback === "saving" - ? t("toolbar.saving") - : docState.saveFeedback === "saved" - ? t("toolbar.saved") - : null} - - ) : null} - - - {renderWindowControls("trailing")} -
-
-
+ void docState.closeTab(p)} + onCloseAllTabs={() => void docState.closeAllTabs()} + onRenameTab={onRenameTabFromBar} + tauriDragExclude={tauriRuntime} + sidebarOpen={sidebarOpen} + onToggleSidebar={() => setSidebarOpen((o) => !o)} + rightPanelOpen={rightPanelOpen} + onToggleRightPanel={() => setRightPanelOpen((o) => !o)} + workspaceReady={workspaceReady} + editorUsable={editorUsable} + saveDisabled={saveDisabled} + saving={docState.saving} + saveFeedback={docState.saveFeedback} + onSave={() => void docState.handleSave()} + onOpenWorkspaceSearch={() => setWorkspaceSearchOpen(true)} + tauriRuntime={tauriRuntime} + isMacPlatform={isMacPlatform} + tauriDragExcludeProps={tauriDragExcludeProps} + tauriWindowDragProps={tauriWindowDragProps} + onTitlebarMouseDown={handleTitlebarMouseDown} + onTitlebarDoubleClick={handleTitlebarDoubleClick} + appWindow={appWindow} + />
void changeView(v)} onOpenCognitiveReport={() => setCognitiveReportOpen(true)} onOpenSettings={() => setAiSettingsOpen(true)} + reviewDueCount={reviewDueTabCount} />
-
- {leftPanelView === "graph" ? ( -
- { - setLeftPanelView("files"); - void onOpenCoachMarkdownPath(relPath); - }} - /> -
- ) : leftPanelView === "thoughts" ? ( - thoughtManagementSessionActive ? ( -
- - { - setLeftPanelView("files"); - void onOpenCoachMarkdownPath(relPath); - }} - isPathKfPrivate={isPathKfPrivate} - /> - -
- ) : null - ) : ( -
- {docState.activePath != null && - docState.hasDiskStaleConflict(docState.activePath) && - editorUsable && ( -
- {t("diskNotice.text")} -
- - -
-
- )} - {docState.saveError ? ( -
- {docState.saveError} - -
- ) : null} -
- {docState.activePath ? ( -
- {loadingDoc &&

{t("main.loading")}

} - {!loadingDoc && loadError && ( -

{t("main.loadError", { details: loadError })}

- )} - {!loadingDoc && !loadError && current && ( - <> -
- - - {docState.activePath} - -
- -
-
-
-
-
- - {t("settings.loading")} -
- } - > - { - crepeEditorApiRef.current = api; - }} - onEditorDispose={() => { - crepeEditorApiRef.current = null; - }} - onSaveAsThought={setEditorSaveThoughtText} - /> - -
- {showMarkdownSource ? ( -