diff --git a/core/Cargo.lock b/core/Cargo.lock index 42c8ef1..3f4d0b3 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -553,8 +553,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -2216,6 +2218,7 @@ dependencies = [ "argon2", "async-trait", "base64 0.22.1", + "chrono", "governor", "rand_core 0.6.4", "reqwest 0.12.28", diff --git a/core/Cargo.toml b/core/Cargo.toml index b8d0d15..56a7c65 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2.11", default-features = false, features = ["wry"] } +tauri = { version = "2.11", default-features = false, features = ["wry", "test"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -34,3 +34,5 @@ async-trait = "0.1" tiktoken-rs = "0.5" rfd = "0.15" governor = "0.6" +chrono = { version = "0.4", features = ["serde"] } + diff --git a/core/src/chat.rs b/core/src/chat.rs index 0ef3ed9..795a121 100644 --- a/core/src/chat.rs +++ b/core/src/chat.rs @@ -34,7 +34,7 @@ pub fn get_chat_history(db: &Connection) -> Result, crate::AppE "SELECT id, role, content, created_at FROM session_messages WHERE session_id = ?1 - ORDER BY created_at ASC, id ASC;", + ORDER BY created_at ASC, rowid ASC;", ) .map_err(|err| { eprintln!("Database error preparing chat history query: {err}"); diff --git a/core/src/lib.rs b/core/src/lib.rs index 5c76dde..c3b545d 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use chat::ChatMessage; +use rusqlite::OptionalExtension; use rusqlite::{params, Connection, Row}; use serde::Serialize; use tauri::Manager; @@ -411,6 +412,7 @@ pub async fn execute_memory_extraction_pipeline( endpoint: String, model: String, db_path: PathBuf, + correction_signal: Option, ) -> Result { // 1. Load and filter chat history synchronously within scoped block to drop connection before await let chat_history = { @@ -421,7 +423,7 @@ pub async fn execute_memory_extraction_pipeline( "SELECT id, role, content, node_refs, created_at FROM session_messages WHERE session_id = 'default-session' - ORDER BY created_at ASC, id ASC;", + ORDER BY created_at ASC, rowid ASC;", ) .map_err(|err| format!("Failed preparing session_messages query: {err}"))?; @@ -592,17 +594,23 @@ pub async fn execute_memory_extraction_pipeline( // 7. Reuse a single connection for changeset build, persist, and retrieval let mut conn = open_connection(&db_path)?; - let changeset_id = { + let changeset_id: String = if let Some(ref signal) = correction_signal { + let (id, _amended) = memory_agent::amendment::amend_or_create_changeset( + &mut conn, + &candidates, + "default-session", + &model, + signal, + )?; + id + } else { let tx = conn .transaction() .map_err(|err| format!("Failed to start transaction: {err}"))?; - let pending_changeset = memory_agent::changeset::build_changeset(&tx, &candidates, "default-session")?; - let persisted_id = memory_agent::persistence::persist_changeset(&tx, &pending_changeset, Some(&model))?; - tx.commit() .map_err(|err| format!("Failed to commit transaction: {err}"))?; persisted_id @@ -626,7 +634,23 @@ async fn memory_extract( // 2. Execute shared pipeline let db_path = state.db_path.clone(); - execute_memory_extraction_pipeline(provider, endpoint, model, db_path).await + execute_memory_extraction_pipeline(provider, endpoint, model, db_path, None).await +} + +fn fetch_latest_user_message( + conn: &Connection, + session_id: &str, +) -> Result, String> { + conn.query_row( + "SELECT content FROM session_messages + WHERE session_id = ?1 AND role = 'user' + ORDER BY created_at DESC, rowid DESC + LIMIT 1;", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("Failed querying latest user message: {err}")) } #[tauri::command] @@ -657,7 +681,22 @@ async fn memory_extract_if_ready( memory_agent::trigger::align_last_extract_count(&conn, current_message_count)?; // 5. Check trigger - let ready = memory_agent::trigger::should_extract(&conn, session_id)?; + let mut ready = memory_agent::trigger::should_extract(&conn, session_id)?; + let mut correction_signal = None; + + // Always scan the latest user message for a correction signal to ensure + // corrections are correctly routed to the amendment engine instead of creating duplicates, + // even if the standard extraction cooldown has already elapsed. + let latest_user_msg = fetch_latest_user_message(&conn, session_id)?; + if let Some(msg_content) = latest_user_msg { + let signal = + memory_agent::trigger::should_extract_correction(&conn, session_id, &msg_content)?; + if signal.is_some() { + ready = true; + correction_signal = signal; + } + } + if !ready { return Ok(None); } @@ -667,8 +706,14 @@ async fn memory_extract_if_ready( // 5. Execute shared pipeline (capture result without early-returning on error, // so we always mark the extraction as attempted and respect cooldown windows) - let pipeline_result = - execute_memory_extraction_pipeline(provider, endpoint, model, db_path.clone()).await; + let pipeline_result = execute_memory_extraction_pipeline( + provider, + endpoint, + model, + db_path.clone(), + correction_signal, + ) + .await; // 6. Mark extraction complete/attempted *before* propagating any error, // so that should_extract respects the 6-message and 2-minute cooldown @@ -3522,6 +3567,7 @@ pub fn run() { save_markdown_file, memory_extract, memory_extract_if_ready, + memory_extract_force, changeset_count_pending, changeset_list_pending, changeset_list_items, @@ -3535,3 +3581,70 @@ pub fn run() { std::process::exit(1); }); } + +#[tauri::command] +async fn memory_extract_force( + provider: String, + endpoint: String, + model: String, + state: tauri::State<'_, AppState>, +) -> Result { + check_rate_limit("memory_agent")?; + let db_path = state.db_path.clone(); + let conn = open_connection(&db_path)?; + + // Verify minimum message threshold (at least 3 messages) + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = 'default-session';", + [], + |row| row.get(0), + ) + .map_err(|err| format!("Failed querying message count: {err}"))?; + if count < 3 { + return Err("Need at least 3 messages to extract memory.".to_string()); + } + + // Since memory_extract_force is a manual bypass/trigger, check if there's a correction + // signal in the latest user message to see if we should run it as a correction. + let latest_user_msg = fetch_latest_user_message(&conn, "default-session")?; + let correction_signal = if let Some(msg_content) = latest_user_msg { + memory_agent::trigger::should_extract_correction(&conn, "default-session", &msg_content)? + } else { + None + }; + + drop(conn); + + // Execute pipeline with the detected correction signal + let result = execute_memory_extraction_pipeline( + provider, + endpoint, + model, + db_path.clone(), + correction_signal, + ) + .await; + + // Mark extraction complete + let conn = open_connection(&db_path)?; + memory_agent::trigger::mark_extraction_complete(&conn, count)?; + + result +} + +pub async fn test_helper_memory_extract_force( + provider: String, + endpoint: String, + model: String, + db_path: std::path::PathBuf, +) -> Result { + use tauri::Manager; + let app = tauri::test::mock_app(); + app.manage(AppState { + db_path, + redacted_session_key: std::sync::Mutex::new(None), + }); + let state = app.state::(); + memory_extract_force(provider, endpoint, model, state).await +} diff --git a/core/src/memory_agent/amendment.rs b/core/src/memory_agent/amendment.rs new file mode 100644 index 0000000..6cbe1d7 --- /dev/null +++ b/core/src/memory_agent/amendment.rs @@ -0,0 +1,260 @@ +use rusqlite::{params, Connection}; +use serde_json; + +use crate::memory_agent; + +// ───────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn chrono_now_iso() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +/// Extracts a lowercase `"title summary"` string from a proposed_data JSON value +/// for use as the Jaccard comparison fingerprint. +fn candidate_fingerprint(proposed_data: &serde_json::Value) -> String { + let title = proposed_data + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let summary = proposed_data + .get("summary") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + format!("{} {}", title, summary) +} + +/// Injects `_amended` metadata into an existing proposed_data JSON value. +/// The key is prefixed with `_` so it sorts before all plain field names under +/// BTreeMap (serde_json default) and is also first when `preserve_order` / +/// IndexMap is enabled (explicit prepend). +/// +/// Only called on the UPDATE (similarity-match) path — new inserts stay clean. +/// The caller always passes a valid JSON object, so this mutates in-place. +fn stamp_amended( + proposed_data: &mut serde_json::Value, + similarity: f64, + correction_signal: &crate::memory_agent::CorrectionSignal, +) { + if let Some(obj) = proposed_data.as_object_mut() { + obj.insert( + "_amended".to_string(), + serde_json::json!({ + "at": chrono_now_iso(), + "similarity": similarity, + "reason": format!("{:?}", correction_signal), + }), + ); + } +} + +/// Finds the most-recent pending changeset for a (session_id, model) pair. +/// Returns `Some(changeset_id)` or `None`. +fn find_pending_changeset( + conn: &Connection, + session_id: &str, + model: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT id FROM changesets + WHERE session_id = ?1 AND model_used = ?2 AND status = 'pending' + ORDER BY created_at DESC LIMIT 1", + ) + .map_err(|e| format!("Database error: {}", e))?; + + let mut rows = stmt + .query(params![session_id, model]) + .map_err(|e| format!("Database error: {}", e))?; + + if let Some(row) = rows.next().map_err(|e| format!("Database error: {}", e))? { + let id: String = row.get(0).map_err(|e| format!("Database error: {}", e))?; + Ok(Some(id)) + } else { + Ok(None) + } +} + +/// Loads all `changeset_items` rows for a given changeset, returning +/// `(item_id, proposed_data)` pairs. Runs inside the caller's transaction. +fn load_pending_items( + conn: &Connection, + changeset_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare( + "SELECT id, proposed_data FROM changeset_items \ + WHERE changeset_id = ?1 AND status = 'pending'", + ) + .map_err(|e| format!("Database error: {}", e))?; + + let mut rows = stmt + .query(params![changeset_id]) + .map_err(|e| format!("Database error: {}", e))?; + + let mut items = Vec::new(); + while let Some(row) = rows.next().map_err(|e| format!("Database error: {}", e))? { + let item_id: String = row.get(0).map_err(|e| format!("Database error: {}", e))?; + let proposed_raw: String = row.get(1).map_err(|e| format!("Database error: {}", e))?; + let proposed_data: serde_json::Value = serde_json::from_str(&proposed_raw) + .map_err(|e| format!("JSON parse error on item {}: {}", item_id, e))?; + items.push((item_id, proposed_data)); + } + + Ok(items) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/// Either amends an existing pending changeset or creates a fresh one. +/// +/// Returns `(changeset_id, amended)` where `amended` is: +/// - `false` — a brand-new changeset was created. +/// - `true` — an existing pending changeset was updated in-place. +/// +/// `correction_signal` is used only on the amend path to populate `_amended.reason` +/// in `proposed_data`; it is ignored when creating a new changeset. +pub fn amend_or_create_changeset( + conn: &mut Connection, + candidates: &[crate::memory_agent::CandidateNode], + session_id: &str, + model: &str, + correction_signal: &crate::memory_agent::CorrectionSignal, +) -> Result<(String, bool), String> { + let tx = conn + .transaction() + .map_err(|err| format!("Failed to start transaction: {err}"))?; + + // ── 1. Check for an existing pending changeset ──────────────────────────── + let existing_changeset = find_pending_changeset(&tx, session_id, model)?; + + // ── 2a. No pending changeset — create a fresh one ──────────────────────── + if existing_changeset.is_none() { + let pending_changeset = + memory_agent::changeset::build_changeset(&tx, candidates, session_id)?; + + let persisted_id = + memory_agent::persistence::persist_changeset(&tx, &pending_changeset, Some(model))?; + + tx.commit() + .map_err(|err| format!("Failed to commit transaction: {err}"))?; + + return Ok((persisted_id, false)); + } + + // ── 2b. Pending changeset exists — amend in-place where possible ───────── + let existing_id = + existing_changeset.ok_or_else(|| "Pending changeset unexpectedly missing".to_string())?; + + // Load existing items once; all comparisons run against this snapshot. + let pending_items = load_pending_items(&tx, &existing_id)?; + + let mut amended_item_ids = std::collections::HashSet::new(); + + for candidate in candidates { + let resolved_vault_id = candidate + .target_vault_key + .as_deref() + .and_then(crate::onboarding::vault_id_for_category_key) + .unwrap_or("vault_root_graph") + .to_string(); + + // Build the base proposed_data for this candidate. + let mut candidate_data = serde_json::json!({ + "title": candidate.title, + "summary": candidate.summary, + "detail": candidate.detail, + "nodeType": candidate.node_type, + "targetVaultKey": candidate.target_vault_key, + "vaultId": resolved_vault_id, + "tags": candidate.tags, + "confidence": candidate.confidence, + "action": candidate.action, + }); + + let candidate_fp = candidate_fingerprint(&candidate_data); + + // Find the highest-similarity existing item above the 50 % threshold. + let best_match = pending_items + .iter() + .filter(|(item_id, _)| !amended_item_ids.contains(item_id)) + .map(|(item_id, existing_data)| { + let existing_fp = candidate_fingerprint(existing_data); + let sim = memory_agent::jaccard_similarity(&candidate_fp, &existing_fp); + (item_id, sim) + }) + .filter(|(_, sim)| *sim > 0.5) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)); + + if let Some((matched_id, similarity)) = best_match { + // Track this item ID as amended so it is excluded from future matches. + amended_item_ids.insert(matched_id.clone()); + + // ── UPDATE path: same candidate, corrected values ───────────────── + // + // Stamp `_amended` metadata so the Diff Panel can render the + // (amended) badge without a schema migration. + stamp_amended(&mut candidate_data, similarity, correction_signal); + + let proposed_json = serde_json::to_string(&candidate_data) + .map_err(|e| format!("JSON serialization error: {}", e))?; + + let item_type = match candidate.action { + crate::memory_agent::CandidateAction::Add => "add", + crate::memory_agent::CandidateAction::Update => "update", + crate::memory_agent::CandidateAction::Delete => "delete", + }; + + tx.execute( + "UPDATE changeset_items + SET proposed_data = ?1, + similarity = ?2, + item_type = ?3, + reviewed_at = NULL + WHERE id = ?4", + params![proposed_json, similarity, item_type, matched_id], + ) + .map_err(|e| format!("Failed to update changeset_item {}: {}", matched_id, e))?; + } else { + // ── INSERT path: genuinely new candidate, no _amended stamp ─────── + let new_item_id = crate::generate_id(&tx, "item") + .map_err(|e| format!("Failed generating item id: {e}"))?; + let proposed_json = serde_json::to_string(&candidate_data) + .map_err(|e| format!("JSON serialization error: {}", e))?; + + let item_type = match candidate.action { + crate::memory_agent::CandidateAction::Add => "add", + crate::memory_agent::CandidateAction::Update => "update", + crate::memory_agent::CandidateAction::Delete => "delete", + }; + + tx.execute( + "INSERT INTO changeset_items + (id, changeset_id, item_type, proposed_data, reviewed_at, sort_order) + VALUES (?1, ?2, ?3, ?4, NULL, + COALESCE( + (SELECT MAX(sort_order) + 1 FROM changeset_items WHERE changeset_id = ?2), + 0 + ))", + params![new_item_id, existing_id, item_type, proposed_json], + ) + .map_err(|e| format!("Failed to insert new changeset_item: {}", e))?; + + tx.execute( + "UPDATE changesets SET item_count = item_count + 1 WHERE id = ?1", + params![existing_id], + ) + .map_err(|e| format!("Failed to increment item_count: {}", e))?; + } + } + + tx.commit() + .map_err(|err| format!("Failed to commit transaction: {err}"))?; + + Ok((existing_id, true)) +} diff --git a/core/src/memory_agent/correction.rs b/core/src/memory_agent/correction.rs new file mode 100644 index 0000000..c7d2920 --- /dev/null +++ b/core/src/memory_agent/correction.rs @@ -0,0 +1,216 @@ +//! This module defines the logic for detecting correction signals in user messages. + +fn contains_phrase_with_boundaries(message: &str, phrase: &str) -> bool { + let msg_len = message.len(); + let phrase_len = phrase.len(); + if phrase_len == 0 { + return true; + } + + let mut start = 0; + while let Some(pos) = message[start..].find(phrase) { + let abs_pos = start + pos; + let end_pos = abs_pos + phrase_len; + + let before_ok = if abs_pos == 0 { + true + } else { + message[..abs_pos] + .chars() + .next_back() + .is_none_or(|c| !c.is_alphanumeric()) + }; + + let after_ok = if end_pos >= msg_len { + true + } else { + message[end_pos..] + .chars() + .next() + .is_none_or(|c| !c.is_alphanumeric()) + }; + + if before_ok && after_ok { + return true; + } + let char_len = message[abs_pos..] + .chars() + .next() + .map_or(1, |c| c.len_utf8()); + start = abs_pos + char_len; + } + false +} + +/// Evaluates whether a user message contains correction signals. +/// Returns `Some(CorrectionSignal)` if detected, `None` otherwise. +pub fn detect_correction_signal( + message: &str, + previous_message: Option<&str>, + pending_proposed_data: &[String], +) -> Option { + let message_lower = message.to_lowercase(); + + // 1. Explicit Phrase Scan + for phrase in CORRECTION_PHRASES { + if contains_phrase_with_boundaries(&message_lower, phrase) { + return Some(CorrectionSignal::ExplicitPhrase { + phrase: phrase.to_string(), + }); + } + } + + // 2. Direct Negation Scan + if let Some(prev) = previous_message { + use std::collections::HashSet; + let mut prev_words = HashSet::new(); + for word in prev.to_lowercase().split_whitespace() { + let clean_word = word.trim_matches(|c: char| !c.is_alphanumeric()); + if !clean_word.is_empty() + && crate::memory_agent::similarity::STOPWORDS + .binary_search(&clean_word) + .is_err() + { + prev_words.insert(clean_word.to_string()); + } + } + + let current_words: Vec<&str> = message_lower.split_whitespace().collect(); + for i in 0..current_words.len() { + let clean_curr = current_words[i].trim_matches(|c: char| !c.is_alphanumeric()); + if clean_curr == "not" || clean_curr == "no" { + if let Some(next_word) = current_words.get(i + 1) { + let clean_next = next_word.trim_matches(|c: char| !c.is_alphanumeric()); + if prev_words.contains(clean_next) { + return Some(CorrectionSignal::Negation { + negated_fragment: clean_next.to_string(), + }); + } + } + } + } + } + + // Check for contradictions with pending proposed data (title and summary only) + for pending_raw in pending_proposed_data { + if let Ok(val) = serde_json::from_str::(pending_raw) { + if let Some(title) = val.get("title").and_then(|t| t.as_str()) { + let title_lower = title.to_lowercase(); + if contains_phrase_with_boundaries(&message_lower, &format!("not {}", title_lower)) + || contains_phrase_with_boundaries( + &message_lower, + &format!("{} is wrong", title_lower), + ) + { + return Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: title.to_string(), + }); + } + } + if let Some(summary) = val.get("summary").and_then(|s| s.as_str()) { + let summary_lower = summary.to_lowercase(); + if contains_phrase_with_boundaries( + &message_lower, + &format!("not {}", summary_lower), + ) || contains_phrase_with_boundaries( + &message_lower, + &format!("{} is wrong", summary_lower), + ) { + return Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: summary.to_string(), + }); + } + } + } + } + + None +} + +#[derive(Debug, Clone, PartialEq)] +pub enum CorrectionSignal { + /// Explicit correction phrases: "actually," "wait," "I meant," "not X, Y," etc. + ExplicitPhrase { phrase: String }, + /// Direct negation of a prior message value + Negation { negated_fragment: String }, + /// Contradiction of a field in a pending changeset item + ChangesetContradiction { contradicted_field: String }, +} + +const CORRECTION_PHRASES: &[&str] = &[ + "actually", + "actually,", + "wait,", + "wait", + "i meant", + "not that", + "correction", + "correction:", + "to clarify", + "scratch that", + "never mind", + "nevermind", + "no wait", + "i was wrong", + "let me correct", + "that's wrong", + "that's not right", + "i misspoke", +]; + +/// Returns `true` if any correction signal is detected in the message. +pub fn has_correction_signal( + message: &str, + previous_message: Option<&str>, + pending_proposed_data: &[String], +) -> bool { + detect_correction_signal(message, previous_message, pending_proposed_data).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_contains_phrase_with_boundaries_unicode_start() { + // This test would trigger a panic with the original `start = abs_pos + 1` logic + assert!(!contains_phrase_with_boundaries("a⚠️test", "⚠️test")); + } + + #[test] + fn test_negation_scan_ignores_punctuation() { + let prev = "My favorite color is blue."; + let current = "not blue"; + let signal = detect_correction_signal(current, Some(prev), &[]); + assert_eq!( + signal, + Some(CorrectionSignal::Negation { + negated_fragment: "blue".to_string() + }) + ); + } + + #[test] + fn test_negation_scan_respects_word_boundaries() { + let prev = "My favorite color is blue."; + let current = "not blueprint"; + let signal = detect_correction_signal(current, Some(prev), &[]); + assert_eq!(signal, None); + + let current_ok = "not blue"; + let signal_ok = detect_correction_signal(current_ok, Some(prev), &[]); + assert!(signal_ok.is_some()); + } + + #[test] + fn test_negation_scan_ignores_stopwords() { + let prev = "It is to be or not to be."; + let current = "not to"; + let signal = detect_correction_signal(current, Some(prev), &[]); + assert_eq!(signal, None); + + let current_neutral = "is it correct"; + let signal_neutral = detect_correction_signal(current_neutral, Some(prev), &[]); + assert_eq!(signal_neutral, None); + } +} diff --git a/core/src/memory_agent/mod.rs b/core/src/memory_agent/mod.rs index 464c7a7..ea47dc8 100644 --- a/core/src/memory_agent/mod.rs +++ b/core/src/memory_agent/mod.rs @@ -1,13 +1,16 @@ +pub mod amendment; pub mod changeset; pub mod commit; +pub mod correction; pub mod parser; pub mod persistence; pub mod prompt; pub mod similarity; pub mod trigger; - +pub use amendment::amend_or_create_changeset; pub use changeset::{build_changeset, ChangesetItemType, PendingChangeset, PendingChangesetItem}; pub use commit::commit_changeset_transaction; +pub use correction::{detect_correction_signal, has_correction_signal, CorrectionSignal}; pub use parser::{ parse_candidates_from_llm_output, parse_candidates_json, CandidateAction, CandidateNode, }; @@ -20,4 +23,6 @@ pub use similarity::{ classify_similarity, compute_text_similarity, jaccard_similarity, tokenize, SimilarityClass, SIMILARITY_DUPLICATE, SIMILARITY_FLAG, }; -pub use trigger::{align_last_extract_count, mark_extraction_complete, should_extract}; +pub use trigger::{ + align_last_extract_count, mark_extraction_complete, should_extract, should_extract_correction, +}; diff --git a/core/src/memory_agent/similarity.rs b/core/src/memory_agent/similarity.rs index 0534ab0..0665e57 100644 --- a/core/src/memory_agent/similarity.rs +++ b/core/src/memory_agent/similarity.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; -const STOPWORDS: &[&str] = &[ +pub const STOPWORDS: &[&str] = &[ "a", "about", "above", diff --git a/core/src/memory_agent/trigger.rs b/core/src/memory_agent/trigger.rs index 16432c5..b2f82c0 100644 --- a/core/src/memory_agent/trigger.rs +++ b/core/src/memory_agent/trigger.rs @@ -1,3 +1,4 @@ +use crate::memory_agent::correction; use rusqlite::{params, Connection, OptionalExtension}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -112,6 +113,57 @@ pub fn mark_extraction_complete( Ok(()) } +/// Evaluates whether a correction signal should bypass the standard debounce gate. +/// Returns true if a correction was detected AND there are at least 3 messages +/// in the session (minimum viable context for extraction). +pub fn should_extract_correction( + conn: &Connection, + session_id: &str, + message: &str, +) -> Result, String> { + // 1. Check message count threshold (3) first to avoid redundant queries in early sessions + let message_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session_messages WHERE session_id = ?1;", + [session_id], + |row| row.get(0), + ) + .map_err(|err| format!("Failed querying session message count: {err}"))?; + + if message_count < 3 { + return Ok(None); + } + + // 2. Query latest user message prior to this one in session + let previous_message: Option = conn + .query_row( + "SELECT content FROM session_messages WHERE session_id = ?1 AND role = 'user' ORDER BY created_at DESC, rowid DESC LIMIT 1 OFFSET 1;", + [session_id], + |row| row.get(0), + ) + .optional() + .map_err(|err| format!("Failed querying latest message: {err}"))?; + + // 3. Query all pending changeset_items with status 'pending' for this session and extract their proposed_data column values + let pending_data: Vec = conn + .prepare( + "SELECT ci.proposed_data \ + FROM changeset_items ci \ + JOIN changesets c ON ci.changeset_id = c.id \ + WHERE ci.status = 'pending' AND c.session_id = ?1;", + ) + .map_err(|err| format!("Failed preparing pending changeset query: {err}"))? + .query_map([session_id], |row| row.get(0)) + .map_err(|err| format!("Failed querying pending changeset items: {err}"))? + .collect::, _>>() + .map_err(|err| format!("Failed reading pending changeset row: {err}"))?; + + let signal = + correction::detect_correction_signal(message, previous_message.as_deref(), &pending_data); + + Ok(signal) +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/tests/correction_amendment.rs b/core/tests/correction_amendment.rs new file mode 100644 index 0000000..9d768d7 --- /dev/null +++ b/core/tests/correction_amendment.rs @@ -0,0 +1,545 @@ +use std::error::Error; +use std::fs; +use std::path::PathBuf; + +use rusqlite::{params, Connection}; + +use mindvault_lib::memory_agent::{ + amend_or_create_changeset, detect_correction_signal, list_changeset_items, + list_pending_changesets, mark_extraction_complete, persist_changeset, should_extract, + should_extract_correction, CandidateAction, CandidateNode, ChangesetItemType, CorrectionSignal, + PendingChangeset, PendingChangesetItem, +}; + +fn apply_migrations(conn: &Connection) -> Result<(), Box> { + let migrations_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("db") + .join("migrations"); + + let mut paths: Vec = fs::read_dir(&migrations_dir)? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "sql")) + .collect(); + + paths.sort(); + + for path in paths { + let sql = fs::read_to_string(&path)?; + conn.execute_batch(&sql)?; + } + Ok(()) +} + +fn setup_test_db() -> Result> { + let conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + "#, + )?; + + apply_migrations(&conn)?; + + Ok(conn) +} + +fn create_test_session(conn: &Connection, id: &str, vault_id: &str) -> Result<(), Box> { + conn.execute( + "INSERT INTO sessions (id, vault_id) VALUES (?1, ?2);", + params![id, vault_id], + )?; + Ok(()) +} + +fn insert_test_message( + conn: &Connection, + id: &str, + session_id: &str, + role: &str, + content: &str, +) -> Result<(), Box> { + conn.execute( + "INSERT INTO session_messages (id, session_id, role, content) VALUES (?1, ?2, ?3, ?4);", + params![id, session_id, role, content], + )?; + Ok(()) +} + +#[test] +fn test_detect_explicit_correction_phrase() { + let phrases = vec!["actually", "wait,", "i meant"]; + for phrase in phrases { + let msg = format!("{} it should be green", phrase); + let signal = detect_correction_signal(&msg, None, &[]); + assert!( + matches!(signal, Some(CorrectionSignal::ExplicitPhrase { .. })), + "Expected ExplicitPhrase for msg: {}", + msg + ); + } +} + +#[test] +fn test_detect_negation_of_previous_message() { + let previous_message = "My favorite color is blue"; + let current_message = "not blue, it's green"; + let signal = detect_correction_signal(current_message, Some(previous_message), &[]); + assert_eq!( + signal, + Some(CorrectionSignal::Negation { + negated_fragment: "blue".to_string() + }) + ); +} + +#[test] +fn test_detect_changeset_contradiction() -> Result<(), Box> { + let pending_data = vec![r#"{"title": "Blue Theme"}"#.to_string()]; + + // 1. Direct contradiction check without using explicit phrases (which would trigger early return) + let signal = detect_correction_signal("Blue Theme is wrong", None, &pending_data); + assert_eq!( + signal, + Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: "Blue Theme".to_string() + }) + ); + + // 2. Direct contradiction check with "not" + let signal_not = detect_correction_signal("not Blue Theme", None, &pending_data); + assert_eq!( + signal_not, + Some(CorrectionSignal::ChangesetContradiction { + contradicted_field: "Blue Theme".to_string() + }) + ); + + // 3. User requested feed message: "actually it should be green theme" + // Note: This message contains "actually" which triggers the ExplicitPhrase scan early. + let signal_actually = + detect_correction_signal("actually it should be green theme", None, &pending_data); + assert_eq!( + signal_actually, + Some(CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string() + }) + ); + + Ok(()) +} + +#[test] +fn test_no_false_positive_on_neutral_message() { + let signal = detect_correction_signal("tell me about rust", None, &[]); + assert!(signal.is_none()); +} + +#[test] +fn test_should_extract_correction_bypasses_debounce() -> Result<(), Box> { + let conn = setup_test_db()?; + let session_id = "default-session"; + create_test_session(&conn, session_id, "vault_learning")?; + + // Insert 3 messages (minimum threshold for correction check) + insert_test_message(&conn, "msg_1", session_id, "user", "Hello")?; + insert_test_message(&conn, "msg_2", session_id, "assistant", "Hi")?; + insert_test_message(&conn, "msg_3", session_id, "user", "Let's study Rust")?; + + // Mark extraction complete at 3 messages (this starts the debounce) + mark_extraction_complete(&conn, 3)?; + + // should_extract must return false because debounce is active + let ready = should_extract(&conn, session_id)?; + assert!(!ready); + + // should_extract_correction must return Some(CorrectionSignal) (correction message bypasses debounce) + let ready_correction = should_extract_correction(&conn, session_id, "actually I meant Go")?; + assert!(ready_correction.is_some()); + + Ok(()) +} + +#[test] +fn test_should_extract_correction_filters_by_session() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_a = "session-A"; + let session_b = "session-B"; + + // Setup vaults and sessions + let _ = conn.execute( + "INSERT OR IGNORE INTO vaults (id, name, privacy_tier) VALUES ('vault-root', 'Vault Root', 'open');", + [], + ); + create_test_session(&conn, session_a, "vault-root")?; + create_test_session(&conn, session_b, "vault-root")?; + + // Create a pending changeset for session_b containing "Blue Theme" + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_b.to_string(), + model_used: Some("llama3".to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + persist_changeset(&tx, &pending_changeset, Some("llama3"))?; + tx.commit()?; + + // Insert 3 messages in session_a + insert_test_message(&conn, "msg_a1", session_a, "user", "Hello")?; + insert_test_message(&conn, "msg_a2", session_a, "assistant", "Hi")?; + insert_test_message(&conn, "msg_a3", session_a, "user", "Some query")?; + + // Insert 3 messages in session_b + insert_test_message(&conn, "msg_b1", session_b, "user", "Hello")?; + insert_test_message(&conn, "msg_b2", session_b, "assistant", "Hi")?; + insert_test_message(&conn, "msg_b3", session_b, "user", "Some other query")?; + + // If we check session_a for "Blue Theme is wrong", it should return None, + // because "Blue Theme" is in session_b's pending changesets, not session_a's. + let ready_a = should_extract_correction(&conn, session_a, "Blue Theme is wrong")?; + assert!( + ready_a.is_none(), + "Session A should not be contaminated by Session B's changesets" + ); + + // If we check session_b for "Blue Theme is wrong", it should return Some. + let ready_b = should_extract_correction(&conn, session_b, "Blue Theme is wrong")?; + assert!( + ready_b.is_some(), + "Session B should detect contradiction on its own pending changeset" + ); + + Ok(()) +} + +#[test] +fn test_amend_existing_changeset_in_place() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + // Create pending changeset with one item + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: + r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme summary"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_id.to_string(), + model_used: Some(model.to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + let cs_id = persist_changeset(&tx, &pending_changeset, Some(model))?; + tx.commit()?; + + // Get the original item ID + let items_before = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items_before.len(), 1); + let original_item_id = items_before[0].id.clone(); + + // Corrected candidate with similarity > 0.5 (Jaccard = 6/8 = 0.75) + let candidates = vec![CandidateNode { + title: "Green Theme".to_string(), + summary: "This is a beautiful green theme summary".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("personal".to_string()), + tags: None, + confidence: 0.95, + action: CandidateAction::Add, + }]; + + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (returned_cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // Assert original changeset_items row was updated in-place, not duplicated + assert_eq!(returned_cs_id, cs_id); + assert!(amended); + + let items_after = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items_after.len(), 1); + assert_eq!(items_after[0].id, original_item_id); + + // Verify _amended metadata is present in proposed_data + let parsed_data: serde_json::Value = serde_json::from_str(&items_after[0].proposed_data)?; + assert!(parsed_data.get("_amended").is_some()); + let amended_meta = parsed_data + .get("_amended") + .ok_or("expected _amended metadata")?; + assert!(amended_meta.get("similarity").is_some()); + assert!(amended_meta.get("reason").is_some()); + + Ok(()) +} + +#[test] +fn test_amend_creates_new_when_no_pending_exists() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + let candidates = vec![CandidateNode { + title: "Green Theme".to_string(), + summary: "This is a beautiful green theme summary".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("personal".to_string()), + tags: None, + confidence: 0.95, + action: CandidateAction::Add, + }]; + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // Assert a new changeset is created normally + assert!(!amended); + + let pending_changesets = list_pending_changesets(&conn)?; + assert_eq!(pending_changesets.len(), 1); + assert_eq!(pending_changesets[0].id, cs_id); + + Ok(()) +} + +#[test] +fn test_amend_appends_genuinely_new_candidate() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + // Create pending changeset with item A + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: + r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme summary"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_id.to_string(), + model_used: Some(model.to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + let cs_id = persist_changeset(&tx, &pending_changeset, Some(model))?; + tx.commit()?; + + // Unrelated candidate B (similarity = 0.0) + let candidates = vec![CandidateNode { + title: "Baking Cakes".to_string(), + summary: "How to bake chocolate cakes".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("learning".to_string()), + tags: None, + confidence: 0.9, + action: CandidateAction::Add, + }]; + + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (returned_cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // Assert B is inserted as a new row and item_count is incremented + assert_eq!(returned_cs_id, cs_id); + assert!(amended); + + let items = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items.len(), 2); + + let count: i64 = conn.query_row( + "SELECT item_count FROM changesets WHERE id = ?1;", + params![cs_id], + |row| row.get(0), + )?; + assert_eq!(count, 2); + + Ok(()) +} + +#[test] +fn test_amend_prevents_multiple_candidates_matching_same_item() -> Result<(), Box> { + let mut conn = setup_test_db()?; + let session_id = "test-session"; + let model = "granite4.1:3b"; + + // Create pending changeset with item A + let pending_items = vec![PendingChangesetItem { + item_type: ChangesetItemType::Add, + target_node_id: None, + proposed_data: + r#"{"title":"Blue Theme","summary":"This is a beautiful blue theme summary"}"# + .to_string(), + existing_data: None, + similarity: None, + merge_with_id: None, + }]; + let pending_changeset = PendingChangeset { + session_id: session_id.to_string(), + model_used: Some(model.to_string()), + items: pending_items, + }; + + let tx = conn.transaction()?; + let cs_id = persist_changeset(&tx, &pending_changeset, Some(model))?; + tx.commit()?; + + // Two candidates, both matching "Blue Theme" (> 50% Jaccard similarity) + let candidates = vec![ + CandidateNode { + title: "Blue Theme".to_string(), + summary: "This is a beautiful blue theme summary updated".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("personal".to_string()), + tags: None, + confidence: 0.95, + action: CandidateAction::Add, + }, + CandidateNode { + title: "Blue Theme".to_string(), + summary: "This is a beautiful blue theme summary version two".to_string(), + detail: None, + node_type: Some("concept".to_string()), + target_vault_key: Some("personal".to_string()), + tags: None, + confidence: 0.95, + action: CandidateAction::Add, + }, + ]; + + let correction_signal = CorrectionSignal::ExplicitPhrase { + phrase: "actually".to_string(), + }; + + let (returned_cs_id, amended) = amend_or_create_changeset( + &mut conn, + &candidates, + session_id, + model, + &correction_signal, + )?; + + // The returned changeset should be the same, and it was amended. + assert_eq!(returned_cs_id, cs_id); + assert!(amended); + + // Instead of both overwriting the same row (leading to 1 item), the second one should + // be added as a new row. So we should end up with exactly 2 items. + let items = list_changeset_items(&conn, &cs_id)?; + assert_eq!(items.len(), 2); + + // One of them is the amended one, and the other is a fresh insertion. + let mut count_amended = 0; + for item in &items { + let data: serde_json::Value = serde_json::from_str(&item.proposed_data)?; + if data.get("_amended").is_some() { + count_amended += 1; + } + } + assert_eq!(count_amended, 1); + + Ok(()) +} + +#[test] +fn test_force_extract_minimum_message_threshold() -> Result<(), Box> { + tauri::async_runtime::block_on(async { + let temp_dir = std::env::temp_dir(); + let db_path = temp_dir.join("test_force_extract.db"); + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + + let session_id = "default-session"; + { + let conn = rusqlite::Connection::open(&db_path)?; + conn.pragma_update(None, "foreign_keys", "ON")?; + apply_migrations(&conn)?; + create_test_session(&conn, session_id, "vault_learning")?; + + // Insert fewer than 3 messages (e.g. 2 messages) + insert_test_message(&conn, "msg_1", session_id, "user", "Hello")?; + insert_test_message(&conn, "msg_2", session_id, "assistant", "Hi")?; + } + + // Call memory_extract_force via test_helper_memory_extract_force + let result = mindvault_lib::test_helper_memory_extract_force( + "ollama".to_string(), + "http://localhost:11434".to_string(), + "granite".to_string(), + db_path.clone(), + ) + .await; + + // Assert it returns an error + assert!(result.is_err()); + let err_msg = result.err().ok_or("expected error result")?; + assert!( + err_msg.contains("at least 3 messages"), + "Expected 'at least 3 messages' error, got: {}", + err_msg + ); + + // Clean up + if db_path.exists() { + let _ = std::fs::remove_file(&db_path); + } + + Ok(()) + }) +} diff --git a/core/tests/memory_agent.rs b/core/tests/memory_agent.rs index ae6313d..af5d1d3 100644 --- a/core/tests/memory_agent.rs +++ b/core/tests/memory_agent.rs @@ -444,6 +444,7 @@ fn test_privacy_filtering_excludes_redacted_and_locked() -> Result<(), Box Result format!("http://127.0.0.1:{}", port), "granite".to_string(), db_path.clone(), + None, ) .await; @@ -639,6 +641,7 @@ fn test_full_pipeline_successful_extraction_and_persistence() -> Result<(), Box< format!("http://127.0.0.1:{}", port), "granite".to_string(), db_path.clone(), + None, ) .await; diff --git a/ui/App.tsx b/ui/App.tsx index 1e449c0..39484db 100644 --- a/ui/App.tsx +++ b/ui/App.tsx @@ -24,6 +24,11 @@ import DiffPanel from "./components/DiffPanel"; import styles from "./style/components/MemoryBadge.module.css"; import { countPendingChangesetItems } from "./services/memoryAgent"; import "./style/MonoStyles.css"; +import ChatHistoryPanel from "./components/ChatHistoryPanel"; + +const SIDEBAR_RAIL_WIDTH = 48; + +type RightPanelView = "notes" | "dashboard" | "settings"; function App() { const [onboardingResolved, setOnboardingResolved] = useState(false); @@ -33,6 +38,7 @@ function App() { const [pendingProposalCount, setPendingProposalCount] = useState(0); const [isDiffPanelOpen, setIsDiffPanelOpen] = useState(false); const [selectedChangesetId, setSelectedChangesetId] = useState(null); + const [leftPanelView, setLeftPanelView] = useState<"browse" | "history">("browse"); useEffect(() => { let active = true; @@ -175,8 +181,7 @@ function App() { const [nodeRefreshKey, setNodeRefreshKey] = useState(0); const [isRedactedUnlocked, setIsRedactedUnlocked] = useState(false); const [selectedVaultRequiresUnlock, setSelectedVaultRequiresUnlock] = useState(false); - const [showDashboard, setShowDashboard] = useState(false); - const [showSettings, setShowSettings] = useState(false); + const [rightPanelView, setRightPanelView] = useState("notes"); const [sidebarModalOpen, setSidebarModalOpen] = useState(false); const [editorModalOpen, setEditorModalOpen] = useState(false); const [spatialModalOpen, setSpatialModalOpen] = useState(false); @@ -190,7 +195,8 @@ function App() { } setViewMode(newMode); }; - const leftPaneExpanded = leftPanePinned && !selectedVaultRequiresUnlock; + const leftPaneExpanded = + leftPanePinned && (leftPanelView !== "browse" || !selectedVaultRequiresUnlock); const rightPaneExpanded = rightPanePinned; const scopeNodeIds = useMemo(() => (selectedNodeId ? [selectedNodeId] : []), [selectedNodeId]); const [assemblerScope, setAssemblerScope] = useState("local"); @@ -321,6 +327,27 @@ function App() { setRightResizing(true); }; + function onLeftRailToggle(view: "browse" | "history") { + if (view === "browse" && selectedVaultRequiresUnlock) { + return; + } + if (leftPanePinned && leftPanelView === view) { + setLeftPanePinned(false); + return; + } + setLeftPanelView(view); + setLeftPanePinned(true); + } + + function onRightRailToggle(view: RightPanelView) { + if (rightPanePinned && rightPanelView === view) { + setRightPanePinned(false); + return; + } + setRightPanelView(view); + setRightPanePinned(true); + } + function closeAllPanes() { // The left pane is meant to be persistently pinned in spatial view, // so clicking the canvas layout does not clear leftPanePinned. @@ -336,8 +363,8 @@ function App() { function onSelectVault(vaultId: string | null) { setSelectedVaultId(vaultId); setSelectedNodeId(null); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); + setLeftPanelView("browse"); setLeftPanePinned(Boolean(vaultId)); setNodeRefreshKey((value) => value + 1); } @@ -345,8 +372,8 @@ function App() { function onFocusVault(vaultId: string | null) { setSelectedVaultId(vaultId); setSelectedNodeId(null); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); + setLeftPanelView("browse"); setNodeRefreshKey((value) => value + 1); } @@ -371,15 +398,13 @@ function App() { function onSelectNode(nodeId: string) { setSelectedNodeId(nodeId); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); setRightPanePinned(true); } function onNodeCreated(nodeId: string) { setSelectedNodeId(nodeId); - setShowDashboard(false); - setShowSettings(false); + setRightPanelView("notes"); setRightPanePinned(true); setNodeRefreshKey((value) => value + 1); } @@ -398,15 +423,13 @@ function App() { function onOpenDashboard() { setSelectedNodeId(null); - setShowDashboard(true); - setShowSettings(false); + setRightPanelView("dashboard"); setRightPanePinned(true); } function onOpenSettings() { setSelectedNodeId(null); - setShowDashboard(false); - setShowSettings(true); + setRightPanelView("settings"); setRightPanePinned(true); } @@ -429,24 +452,15 @@ function App() { } } - const leftToggleStyle = { - left: leftPaneExpanded || sidebarModalOpen ? `${leftPaneWidth + 16}px` : "16px", - zIndex: 1005, - }; - - const rightToggleStyle = { - right: rightPaneExpanded ? `${rightPaneWidth + 48}px` : "48px", - zIndex: 1005, - }; - const zenCanvasStyle = { left: viewMode === "editor" ? "0px" - : leftPaneExpanded || sidebarModalOpen - ? `${leftPaneWidth}px` - : "0px", - right: viewMode === "editor" ? "0px" : rightPaneExpanded ? `${rightPaneWidth}px` : "0px", + : `${SIDEBAR_RAIL_WIDTH + (leftPaneExpanded || sidebarModalOpen ? leftPaneWidth : 0)}px`, + right: + viewMode === "editor" + ? "0px" + : `${SIDEBAR_RAIL_WIDTH + (rightPaneExpanded ? rightPaneWidth : 0)}px`, }; return ( @@ -527,6 +541,37 @@ function App() { )} + {/* Decoupled chat panel, persists regardless of viewmode */} +
+ { + void countPendingChangesetItems() + .then(setPendingProposalCount) + .catch(console.error); + }} + /> +
+ {viewMode === "editor" ? ( selectedNodeId && ( - ) : ( - { - void countPendingChangesetItems() - .then(setPendingProposalCount) - .catch(console.error); - }} - /> - )} + ) : null} {viewMode !== "editor" && ( @@ -582,7 +611,9 @@ function App() { className={`pane-wrap left ${leftPaneExpanded || sidebarModalOpen ? "show" : ""}`} style={{ width: `${leftPaneWidth}px` }} > - {!selectedVaultId ? ( + {leftPanelView === "history" ? ( + + ) : !selectedVaultId ? ( - {showDashboard ? ( + {rightPanelView === "dashboard" ? ( - ) : showSettings ? ( + ) : rightPanelView === "settings" ? ( ) : (
@@ -665,76 +696,132 @@ function App() {
)} - {/* Left Sidebar Toggle Button */} + {/* Left Sidebar Icon Rail */} + {/* Left Sidebar Icon Rail */} {viewMode !== "editor" && ( - + + + + + + + )} - {/* Right Sidebar Toggle Button */} + {/* Right Sidebar Icon Rail */} {viewMode !== "editor" && ( - + + + + + + )} {isDiffPanelOpen && ( diff --git a/ui/components/ChatHistoryPanel.tsx b/ui/components/ChatHistoryPanel.tsx new file mode 100644 index 0000000..6d25536 --- /dev/null +++ b/ui/components/ChatHistoryPanel.tsx @@ -0,0 +1,8 @@ +export default function ChatHistoryPanel() { + return ( +
+

Chat History

+

This is where the chat history would be displayed.

+
+ ); +} diff --git a/ui/components/ChatPanel.tsx b/ui/components/ChatPanel.tsx index 181fd85..e147c77 100644 --- a/ui/components/ChatPanel.tsx +++ b/ui/components/ChatPanel.tsx @@ -13,6 +13,7 @@ import { createMarkdownComponents, preprocessMathDelimiters, preprocessWikiLinks, + ExistingNodesContext, } from "../utils/markdownUtils"; import type { ContextAssemblerScope } from "../constants/contextBudget"; import type { Vault } from "../ipc"; @@ -23,10 +24,10 @@ import { chatEditAndTruncate, type ChatMessage, } from "../services/chat"; -import { chatWithScope } from "../services/nodes"; +import { chatWithScope, getAllNodes } from "../services/nodes"; import { getSetting } from "../services/settings"; import { listVaults } from "../services/vaults"; -import { extractMemoryIfReady } from "../services/memoryAgent"; +import { extractMemoryIfReady, extractMemoryForce } from "../services/memoryAgent"; import { getLlmModel, getLlmProvider, @@ -56,6 +57,8 @@ type ChatMessageBubbleProps = { onStartEdit: (messageId: string, content: string) => void; chartsEnabled: boolean; onSelectNode?: (nodeId: string) => void; + existingNodeIds: Set | null; + isRedactedUnlocked: boolean; }; const ChatMessageBubble = React.memo(function ChatMessageBubble({ @@ -73,6 +76,8 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ onStartEdit, chartsEnabled, onSelectNode, + existingNodeIds, + isRedactedUnlocked, }: ChatMessageBubbleProps) { const bubbleContentRef = useRef(null); const [isOverflowing, setIsOverflowing] = useState(false); @@ -88,8 +93,8 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ }, [message.content, message.role, isEditing]); const markdownComponents = React.useMemo(() => { - return createMarkdownComponents(chartsEnabled, onSelectNode); - }, [chartsEnabled, onSelectNode]); + return createMarkdownComponents(chartsEnabled, onSelectNode, isRedactedUnlocked); + }, [chartsEnabled, onSelectNode, isRedactedUnlocked]); const preprocessedMessage = React.useMemo(() => { const wLinks = preprocessWikiLinks(message.content); @@ -135,13 +140,15 @@ const ChatMessageBubble = React.memo(function ChatMessageBubble({ message.role === "user" && isOverflowing && isCollapsed ? "collapsed" : "" }`} > - - {preprocessedMessage} - + + + {preprocessedMessage} + + {message.role === "user" && isOverflowing && ( + {status &&

{status}

} @@ -1066,6 +1134,8 @@ function ChatPanel({ onStartEdit={handleStartEdit} chartsEnabled={chartsEnabled} onSelectNode={onSelectNode} + existingNodeIds={existingNodeIds} + isRedactedUnlocked={isRedactedUnlocked} /> ))} {isSending && ( @@ -1247,7 +1317,18 @@ function ChatPanel({ )} - + {/* Pill 4: Extract Memory */} +
+ +
{/* Overflow dropdown trigger */}
void; activeChangesetId: string | null; @@ -261,15 +270,6 @@ export default function DiffPanel({ setSelectedCategory(null); }; - // Safe JSON Parsing for proposed/existing data - const parseJSON = (str: string) => { - try { - return JSON.parse(str); - } catch { - return {}; - } - }; - // Helper to extract item summary title const getItemTitle = useCallback((item: ChangesetItem) => { const data = parseJSON(item.proposedData); @@ -589,18 +589,20 @@ export default function DiffPanel({
{/* Detailed diff cards will render here in Commit 4. For Commit 3, we render a highly polished list summary with type badges. */} - {filteredItems.map((item) => ( -
- -
- ))} + {filteredItems.map((item) => { + return ( +
+ +
+ ); + })}
)} diff --git a/ui/components/DiffPanel/DiffRow.tsx b/ui/components/DiffPanel/DiffRow.tsx index a05afaa..7a5f14f 100644 --- a/ui/components/DiffPanel/DiffRow.tsx +++ b/ui/components/DiffPanel/DiffRow.tsx @@ -17,6 +17,7 @@ interface ProposedData { tags?: string[]; vaultId?: string; vault_id?: string; + _amended?: { at?: string; similarity?: number; reason?: string }; } interface ExistingData { @@ -243,6 +244,14 @@ export default function DiffRow({ item, onCommitItem }: DiffRowProps) { {typeUpper === "REPOINT_DOOR" || typeUpper === "ORPHAN_ALERT" ? "ORPHAN" : typeUpper} + {proposed._amended && ( + + (amended) + + )} Status: {item.status}
{ + return new Set(Object.keys(allNodesMap)); + }, [allNodesMap]); + const normalizedTagInput = tagInput.trim().toLowerCase(); const filteredTagOptions = useMemo(() => { @@ -1069,6 +1073,8 @@ function NodeEditor({ onSelectNode={onSelectNode} nodeId={node?.id} onRefreshDoors={() => node?.id && refreshDoors(node.id)} + existingNodeIds={existingNodeIds} + isRedactedUnlocked={isRedactedUnlocked} /> )}
diff --git a/ui/components/NodeEditorDetail.tsx b/ui/components/NodeEditorDetail.tsx index 404b26d..be68033 100644 --- a/ui/components/NodeEditorDetail.tsx +++ b/ui/components/NodeEditorDetail.tsx @@ -8,6 +8,7 @@ import { getCaretCoordinates, isRawLatex, preprocessMathDelimiters, + ExistingNodesContext, } from "../utils/markdownUtils"; import NodeLinkAutocomplete from "./NodeLinkAutocomplete"; import type { Node } from "../types/generated/Node"; @@ -25,6 +26,8 @@ type NodeEditorDetailProps = { onSelectNode?: (nodeId: string) => void; nodeId?: string; onRefreshDoors?: () => void; + existingNodeIds?: Set; + isRedactedUnlocked?: boolean; }; export default function NodeEditorDetail({ @@ -37,6 +40,8 @@ export default function NodeEditorDetail({ onSelectNode, nodeId, onRefreshDoors, + existingNodeIds, + isRedactedUnlocked, }: NodeEditorDetailProps) { const storeChartsEnabled = useUIStore((state) => state.nodeEditor.chartsEnabled); const setNodeEditorChartsEnabled = useUIStore((state) => state.setNodeEditorChartsEnabled); @@ -163,8 +168,8 @@ export default function NodeEditorDetail({ }, [debouncedPreviewValue]); const markdownComponents = React.useMemo(() => { - return createMarkdownComponents(chartsEnabled, onSelectNode); - }, [chartsEnabled, onSelectNode]); + return createMarkdownComponents(chartsEnabled, onSelectNode, isRedactedUnlocked); + }, [chartsEnabled, onSelectNode, isRedactedUnlocked]); return (
@@ -269,13 +274,15 @@ export default function NodeEditorDetail({ ) ) : ( - - {preprocessedMarkdown} - + + + {preprocessedMarkdown} + + )}
)} diff --git a/ui/components/NodeEditorExpanded.tsx b/ui/components/NodeEditorExpanded.tsx index 6a68b66..89dcc6d 100644 --- a/ui/components/NodeEditorExpanded.tsx +++ b/ui/components/NodeEditorExpanded.tsx @@ -8,6 +8,7 @@ import { getCaretCoordinates, isRawLatex, preprocessMathDelimiters, + ExistingNodesContext, } from "../utils/markdownUtils"; import LatexBlock from "./LatexBlock"; import { @@ -783,8 +784,12 @@ export default function NodeEditorExpanded({ }, [debouncedPreviewDetail]); const markdownComponents = React.useMemo(() => { - return createMarkdownComponents(chartsEnabled, onSelectNode); - }, [chartsEnabled, onSelectNode]); + return createMarkdownComponents(chartsEnabled, onSelectNode, isRedactedUnlocked); + }, [chartsEnabled, onSelectNode, isRedactedUnlocked]); + + const existingNodeIds = React.useMemo(() => { + return new Set(Object.keys(allNodesMap)); + }, [allNodesMap]); function getNodeDisplayLabel(item: Node): string { const containerId = item.subVaultId ?? item.vaultId; @@ -1279,13 +1284,15 @@ export default function NodeEditorExpanded({

{editTitle || "Untitled Node"}

{editSummary &&

{editSummary}

}
- - {preprocessedMarkdown} - + + + {preprocessedMarkdown} + + )}
diff --git a/ui/ipc.ts b/ui/ipc.ts index 79528da..92f8899 100644 --- a/ui/ipc.ts +++ b/ui/ipc.ts @@ -334,3 +334,17 @@ export function debugSeedChangeset() { .then((ok) => ({ ok }) as IpcResult) .catch((error) => ({ err: String(error) }) as IpcResult); } + +export function memoryExtractForce( + provider: string, + endpoint: string, + model: string +): Promise> { + return invoke("memory_extract_force", { + provider, + endpoint, + model, + }) + .then((ok) => ({ ok }) as IpcResult) + .catch((error) => ({ err: String(error) }) as IpcResult); +} diff --git a/ui/services/memoryAgent.ts b/ui/services/memoryAgent.ts index 144e8d5..afdd1f1 100644 --- a/ui/services/memoryAgent.ts +++ b/ui/services/memoryAgent.ts @@ -10,6 +10,7 @@ import { type ChangesetCommitInput, changesetListResolved, debugSeedChangeset, + memoryExtractForce, } from "../ipc"; import { clearNodesCache } from "./nodes"; import { unwrapIpcResult } from "./ipcResult"; @@ -42,6 +43,20 @@ export async function extractMemoryIfReady( return result; } +/** + * Forces an immediate memory extraction, bypassing the debounce gate. + * Used by the manual "Extract Now" chat toolbar button. + */ +export async function extractMemoryForce( + provider: string, + endpoint: string, + model: string +): Promise { + const result = await unwrapIpcResult(memoryExtractForce(provider, endpoint, model)); + clearNodesCache(); + return result; +} + /** * Counts total pending changeset items. */ diff --git a/ui/services/nodes.ts b/ui/services/nodes.ts index d2fa34a..9d0fcbf 100644 --- a/ui/services/nodes.ts +++ b/ui/services/nodes.ts @@ -18,10 +18,12 @@ import { unwrapIpcResult } from "./ipcResult"; let cachedNodes: Node[] | null = null; let cachedUnlockState: boolean | null = null; +let pendingNodesPromise: Promise | null = null; export function clearNodesCache(): void { cachedNodes = null; cachedUnlockState = null; + pendingNodesPromise = null; } export async function createNode(input: NodeCreateInput): Promise { @@ -34,20 +36,47 @@ export async function getNode(nodeId: string): Promise { } export async function getNodes(isRedactedUnlocked?: boolean): Promise { + const requestedState = isRedactedUnlocked !== undefined ? isRedactedUnlocked : null; + if (cachedUnlockState !== requestedState) { + cachedNodes = null; + pendingNodesPromise = null; + cachedUnlockState = requestedState; + } + if (isRedactedUnlocked === undefined) { + if (pendingNodesPromise) { + return pendingNodesPromise; + } clearNodesCache(); - return unwrapIpcResult(nodeList()); - } - if (cachedUnlockState !== isRedactedUnlocked) { - cachedNodes = null; - cachedUnlockState = isRedactedUnlocked; + const promise = unwrapIpcResult(nodeList()).finally(() => { + if (pendingNodesPromise === promise) { + pendingNodesPromise = null; + } + }); + pendingNodesPromise = promise; + return promise; } + if (cachedNodes) { return cachedNodes; } - const nodes = await unwrapIpcResult(nodeList()); - cachedNodes = nodes; - return nodes; + if (pendingNodesPromise) { + return pendingNodesPromise; + } + + const promise = unwrapIpcResult(nodeList()).then( + (nodes) => { + cachedNodes = nodes; + pendingNodesPromise = null; + return nodes; + }, + (error) => { + pendingNodesPromise = null; + throw error; + } + ); + pendingNodesPromise = promise; + return promise; } export async function getAllNodes(isRedactedUnlocked?: boolean): Promise { diff --git a/ui/style/App.css b/ui/style/App.css index 34cd016..4b84d10 100644 --- a/ui/style/App.css +++ b/ui/style/App.css @@ -39,92 +39,74 @@ right 220ms ease; } -/* Pinned layout coordinate shifts (Handled dynamically via React inline styles in App.tsx) */ -/* .hybrid-shell.left-pinned .zen-canvas { - left: 280px; -} */ - -/* .hybrid-shell.right-pinned .zen-canvas { - right: min(560px, 52vw); -} */ - -/* @media (max-width: 1100px) { - .hybrid-shell.left-pinned .zen-canvas { - left: 260px; - } -} */ - -/* Exquisite floating sidebar toggle button handles */ -.sidebar-toggle-btn { +/* Persistent icon rails for left/right panel views */ +.icon-rail { position: absolute; - top: 16px; + top: 0; + bottom: 0; + width: 48px; z-index: 1005; - width: 36px; - height: 36px; display: flex; + flex-direction: column; align-items: center; - justify-content: center; - border-radius: 8px; + gap: 8px; + padding: 16px 0; background: rgba(250, 249, 247, 0.85); - border: 1px solid rgba(188, 108, 37, 0.18); - color: var(--primary-color); - cursor: pointer; backdrop-filter: blur(8px); - transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 2px 8px rgba(188, 108, 37, 0.06); } -.hybrid-shell.modal-open .sidebar-toggle-btn, +.icon-rail-left { + left: 0; + border-right: 1px solid rgba(188, 108, 37, 0.18); +} + +.icon-rail-right { + right: 0; + border-left: 1px solid rgba(188, 108, 37, 0.18); +} + +.hybrid-shell.modal-open .icon-rail, .hybrid-shell.modal-open .canvas-view-toggle-pill, -.hybrid-shell:has(.charts-modal-overlay) .sidebar-toggle-btn, +.hybrid-shell:has(.charts-modal-overlay) .icon-rail, .hybrid-shell:has(.charts-modal-overlay) .canvas-view-toggle-pill { display: none !important; } -.sidebar-toggle-btn:hover { - background: rgba(255, 255, 255, 1); - border-color: var(--primary-color); +.icon-rail-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; color: var(--primary-color); - transform: scale(1.05); - box-shadow: 0 4px 12px rgba(188, 108, 37, 0.12); -} - -.sidebar-toggle-btn:disabled { - opacity: 0.35; - cursor: not-allowed; - transform: none; - box-shadow: 0 2px 8px rgba(188, 108, 37, 0.03); + cursor: pointer; + transition: all 220ms cubic-bezier(0.4, 0, 0.2, 1); } -.sidebar-toggle-btn:disabled:hover { - background: rgba(250, 249, 247, 0.85); - border-color: rgba(188, 108, 37, 0.18); +.icon-rail-btn:hover { + background: rgba(188, 108, 37, 0.1); + border-color: rgba(188, 108, 37, 0.2); color: var(--primary-color); - transform: none; - box-shadow: 0 2px 8px rgba(188, 108, 37, 0.03); } -.sidebar-toggle-btn.left { - left: 16px; +.icon-rail-btn.active { + background: rgba(188, 108, 37, 0.18); + border-color: rgba(188, 108, 37, 0.35); + box-shadow: 0 2px 8px rgba(188, 108, 37, 0.12); } -/* .sidebar-toggle-btn.left.open { - left: 296px; -} */ - -.sidebar-toggle-btn.right { - right: 48px; +.icon-rail-btn:disabled { + opacity: 0.35; + cursor: not-allowed; } -/* .sidebar-toggle-btn.right.open { - right: calc(min(560px, 52vw) + 16px); -} */ - -/* @media (max-width: 1100px) { - .sidebar-toggle-btn.left.open { - left: 276px; - } -} */ +.icon-rail-btn:disabled:hover { + background: transparent; + border-color: transparent; +} /* Resizing handles and drag override states */ .hybrid-shell.is-resizing { @@ -134,7 +116,7 @@ .hybrid-shell.is-resizing .pane-wrap, .hybrid-shell.is-resizing .zen-canvas, -.hybrid-shell.is-resizing .sidebar-toggle-btn { +.hybrid-shell.is-resizing .icon-rail { transition: none !important; } @@ -189,7 +171,7 @@ } .pane-wrap.left { - left: 0; + left: 48px; width: 280px; transform: translateX(-100%); } @@ -199,7 +181,7 @@ } .pane-wrap.right { - right: 0; + right: 48px; width: min(560px, 52vw); transform: translateX(100%); } diff --git a/ui/style/MonoStyles.css b/ui/style/MonoStyles.css index c51af83..1286671 100644 --- a/ui/style/MonoStyles.css +++ b/ui/style/MonoStyles.css @@ -14,3 +14,4 @@ @import "./components/ScopeIndicator.css"; @import "./components/VaultSidebar.css"; @import "./components/LatexBlock.css"; +@import "./components/ChatHistoryPanel.css"; diff --git a/ui/style/components/ChatHistoryPanel.css b/ui/style/components/ChatHistoryPanel.css new file mode 100644 index 0000000..a7a61e7 --- /dev/null +++ b/ui/style/components/ChatHistoryPanel.css @@ -0,0 +1,86 @@ +.chat-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.chat-history-item-btn { + width: 100%; + text-align: left; + display: flex; + flex-direction: column; + gap: 2px; + border: 1px solid rgba(188, 108, 37, 0.12); + background: rgba(188, 108, 37, 0.04); + border-radius: 8px; + padding: 8px 10px; + cursor: pointer; + transition: + background 120ms ease, + border-color 120ms ease; +} + +.chat-history-item-btn:hover { + background: rgba(188, 108, 37, 0.1); + border-color: rgba(188, 108, 37, 0.25); +} + +.chat-history-item-btn:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.chat-history-role { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--primary-color); + opacity: 0.8; +} + +.chat-history-preview { + font-size: 0.85rem; + color: var(--text-black); + line-height: 1.35; +} + +.chat-history-item.role-assistant .chat-history-item-btn { + background: rgba(0, 0, 0, 0.02); +} + +.chat-history-edit { + display: flex; + flex-direction: column; + gap: 6px; + border: 1px solid rgba(188, 108, 37, 0.25); + border-radius: 8px; + padding: 8px 10px; + background: rgba(188, 108, 37, 0.06); +} + +.chat-history-edit textarea { + width: 100%; + resize: vertical; + font-family: inherit; + font-size: 0.85rem; + border-radius: 6px; + border: 1px solid rgba(188, 108, 37, 0.2); + padding: 6px 8px; + background: rgba(255, 255, 255, 0.7); + color: var(--text-black); +} + +.chat-history-edit-actions { + display: flex; + gap: 8px; +} + +.chat-history-edit-hint { + margin: 0; + font-size: 0.75rem; + color: rgba(27, 26, 23, 0.6); +} diff --git a/ui/style/components/ChatPanel.css b/ui/style/components/ChatPanel.css index a4d6398..694be08 100644 --- a/ui/style/components/ChatPanel.css +++ b/ui/style/components/ChatPanel.css @@ -2234,3 +2234,22 @@ .chat-message-user .chat-show-more-btn:hover { color: #ffffff; } + +.extract-pill { + border-color: rgba(123, 97, 255, 0.25); +} + +.extract-pill:hover:not(:disabled) { + border-color: rgba(123, 97, 255, 0.5); + background: rgba(123, 97, 255, 0.08); +} + +.extract-pill:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.extract-pill.active { + background: rgba(123, 97, 255, 0.12); + border-color: rgba(123, 97, 255, 0.45); +} diff --git a/ui/style/components/DiffPanel.css b/ui/style/components/DiffPanel.css index 3b69bab..233b6a0 100644 --- a/ui/style/components/DiffPanel.css +++ b/ui/style/components/DiffPanel.css @@ -669,3 +669,17 @@ color: #991b1b; line-height: 1.45; } + +.diff-amended-badge { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 500; + color: rgba(251, 191, 36, 0.9); + background: rgba(251, 191, 36, 0.1); + border: 1px solid rgba(251, 191, 36, 0.2); + border-radius: 6px; + padding: 1px 6px; + margin-left: 6px; + letter-spacing: 0.02em; +} diff --git a/ui/style/components/NodeEditor.css b/ui/style/components/NodeEditor.css index 4cd80cd..3eab54f 100644 --- a/ui/style/components/NodeEditor.css +++ b/ui/style/components/NodeEditor.css @@ -1453,3 +1453,22 @@ .header-action-icon-btn:active { transform: translateY(0); } + +.wikilink-badge.broken { + opacity: 0.65; + text-decoration: line-through; + color: #e53e3e; + border-color: #fc8181; + background: rgba(245, 101, 101, 0.08); + cursor: not-allowed; +} + +.wikilink-badge.broken:hover { + background: rgba(245, 101, 101, 0.12); + border-color: #f56565; +} + +.wikilink-badge.loading { + opacity: 0.7; + cursor: wait; +} diff --git a/ui/utils/markdownUtils.tsx b/ui/utils/markdownUtils.tsx index 596945b..81613da 100644 --- a/ui/utils/markdownUtils.tsx +++ b/ui/utils/markdownUtils.tsx @@ -1,5 +1,6 @@ /* eslint-disable react-refresh/only-export-components */ import React, { useState } from "react"; +import { createPortal } from "react-dom"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import remarkGfm from "remark-gfm"; @@ -165,13 +166,157 @@ export function preprocessMathDelimiters(text: string): string { processed = processed.replace(/\\\\\)/g, "$").replace(/\\\)/g, "$"); return processed; } +export const ExistingNodesContext = React.createContext | null | undefined>(undefined); + +function WikiLinkBadge({ + nodeId, + children, + onSelectNode, + isRedactedUnlocked, +}: { + nodeId: string; + children: React.ReactNode; + onSelectNode?: (nodeId: string) => void; + isRedactedUnlocked?: boolean; +}) { + const existingNodeIds = React.useContext(ExistingNodesContext); + const [fetchedExists, setFetchedExists] = React.useState(null); + const [modal, setModal] = React.useState<{ title: string; message: string } | null>(null); + const isSearchQuery = nodeId.startsWith("search:"); + + // Extract a clean string representation of children for string templates (alert messages & tooltips) + const labelText = React.useMemo(() => { + if (typeof children === "string") return children; + if (typeof children === "number") return String(children); + const extractString = (node: React.ReactNode): string => { + if (!node) return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(extractString).join(""); + if (React.isValidElement(node)) { + return extractString((node.props as { children?: React.ReactNode }).children); + } + return ""; + }; + return extractString(children); + }, [children]); + + const nodeExists = existingNodeIds ? existingNodeIds.has(nodeId) : fetchedExists; + const isBroken = !isSearchQuery && nodeExists === false; + const isLoading = nodeExists === null && !isSearchQuery; + + // Validate node existence on mount (skip for search queries or if context provider is present) + React.useEffect(() => { + if (isSearchQuery || existingNodeIds !== undefined) { + return; // Don't validate search queries or if context provider is present + } + + let isMounted = true; + getAllNodes(isRedactedUnlocked) + .then((nodes) => { + if (isMounted) { + const exists = nodes.some((n) => n.id === nodeId); + setFetchedExists(exists); + } + }) + .catch(() => { + if (isMounted) { + setFetchedExists(false); + } + }); + + return () => { + isMounted = false; + }; + }, [nodeId, isSearchQuery, existingNodeIds, isRedactedUnlocked]); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + if (isBroken) { + setModal({ + title: "Broken Node Connection", + message: `The node "${labelText}" no longer exists in your vault.\n\nYou may need to:\n• Remove this link\n• Create a matching node with this title`, + }); + return; + } + + if (onSelectNode) { + if (isSearchQuery) { + const query = nodeId.substring(7).trim(); + getAllNodes(isRedactedUnlocked) + .then((nodes) => { + const match = nodes.find((n) => n.title.toLowerCase().trim() === query.toLowerCase()); + if (match) { + onSelectNode(match.id); + } else { + setModal({ + title: "Node Not Found", + message: `No node with title "${query}" exists in your vault.`, + }); + } + }) + .catch((err) => console.error("Failed to query nodes for wikilink:", err)); + } else { + onSelectNode(nodeId); + } + } + }; + + return ( + <> + + + {modal && + createPortal( +
setModal(null)}> +
e.stopPropagation()}> +

{modal.title}

+

+ {modal.message} +

+
+ +
+
+
, + document.body + )} + + ); +} /** * Creates stable markdown components overrides */ export function createMarkdownComponents( chartsEnabled: boolean, - onSelectNode?: (nodeId: string) => void + onSelectNode?: (nodeId: string) => void, + isRedactedUnlocked?: boolean ) { return { a({ href, children, ...props }: React.AnchorHTMLAttributes) { @@ -184,40 +329,13 @@ export function createMarkdownComponents( ?.split(/[?#]/)[0] || ""; const decodedNodeId = decodeURIComponent(nodeId); return ( - + {children} + ); } return (