kimun_notes/components/
semantic_search.rs1use std::collections::HashSet;
6use std::sync::Arc;
7
8use async_trait::async_trait;
9use kimun_core::NoteVault;
10use kimun_core::nfs::VaultPath;
11use kimun_server_client::ChunkResult;
12
13use ratatui::Frame;
14use ratatui::layout::Rect;
15
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
18use crate::components::file_list::FileListEntry;
19use crate::components::note_browser::format_journal_date;
20use crate::components::query_list_panel::{ListPanelSpec, QueryListPanel};
21use crate::components::search_list::{Emit, RowSource};
22use crate::rag::rag_client;
23use crate::settings::SharedSettings;
24use crate::settings::icons::Icons;
25use crate::settings::themes::Theme;
26
27fn chunks_to_entries(chunks: Vec<ChunkResult>, vault: &NoteVault) -> Vec<FileListEntry> {
30 let mut seen: HashSet<VaultPath> = HashSet::new();
31 let mut out = Vec::new();
32 for chunk in chunks {
33 let path = VaultPath::new(&chunk.path);
34 if !seen.insert(path.flatten().absolute()) {
36 continue;
37 }
38 let filename = path.get_parent_path().1;
39 let title = if chunk.title.trim().is_empty() {
42 "<no title>".to_string()
43 } else {
44 chunk.title
45 };
46 let journal_date = vault.journal_date(&path).map(format_journal_date);
47 out.push(FileListEntry::Note {
48 path,
49 title,
50 filename,
51 journal_date,
52 is_open: false,
53 });
54 }
55 out
56}
57
58pub struct SemanticSource {
60 vault: Arc<NoteVault>,
61 settings: SharedSettings,
62}
63
64impl SemanticSource {
65 pub fn new(vault: Arc<NoteVault>, settings: SharedSettings) -> Self {
66 Self { vault, settings }
67 }
68}
69
70#[async_trait]
71impl RowSource<FileListEntry> for SemanticSource {
72 async fn load(&self, query: &str, emit: Emit<FileListEntry>) {
73 if query.trim().is_empty() {
74 emit.replace(Vec::new());
75 return;
76 }
77 tokio::time::sleep(std::time::Duration::from_millis(250)).await;
81 let entries = match rag_client(&self.settings, &self.vault).await {
82 Some(client) => match client.search(query, None).await {
83 Ok(chunks) => chunks_to_entries(chunks, &self.vault),
84 Err(e) => {
87 log::debug!("semantic search failed: {e}");
88 Vec::new()
89 }
90 },
91 None => Vec::new(),
92 };
93 emit.replace(entries);
94 }
95}
96
97pub struct SemanticSpec;
100
101impl ListPanelSpec for SemanticSpec {
102 type Row = FileListEntry;
103 const TITLE: &'static str = "Semantic";
104 const HAS_FILTER: bool = true;
105 const BORDERED_INPUT: bool = true;
108 const LOCAL_FILTER: bool = false;
112
113 fn submit(row: &FileListEntry, tx: &AppTx) {
114 if let FileListEntry::Note { path, .. } = row {
115 tx.send(AppEvent::open(path.clone())).ok();
116 }
117 }
118
119 fn context_event(row: &FileListEntry) -> Option<AppEvent> {
120 match row {
121 FileListEntry::Note { path, .. } => {
122 Some(AppEvent::FileOp(FileOp::ShowMenu(path.clone())))
123 }
124 _ => None,
125 }
126 }
127
128 fn hints() -> Vec<(String, String)> {
129 vec![("Enter".into(), "Open".into())]
130 }
131}
132
133pub struct SemanticPanel {
136 vault: Arc<NoteVault>,
137 settings: SharedSettings,
138 body: QueryListPanel<SemanticSpec>,
139 source_installed: bool,
140}
141
142impl SemanticPanel {
143 pub fn new(vault: Arc<NoteVault>, settings: SharedSettings, icons: Icons) -> Self {
144 Self {
145 vault,
146 settings,
147 body: QueryListPanel::new(icons),
148 source_installed: false,
149 }
150 }
151
152 pub fn ensure_source(&mut self, tx: &AppTx) {
154 if !self.source_installed {
155 self.body.set_source(
156 SemanticSource::new(self.vault.clone(), self.settings.clone()),
157 tx,
158 );
159 self.source_installed = true;
160 }
161 }
162
163 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
164 self.body.hint_shortcuts()
165 }
166
167 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
168 self.body.handle_input(event, tx)
169 }
170
171 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
172 self.body.render(f, rect, theme, focused);
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use kimun_core::VaultConfig;
180 use tempfile::TempDir;
181
182 fn chunk(path: &str, title: &str, score: f64) -> ChunkResult {
183 ChunkResult {
184 path: path.to_string(),
185 title: title.to_string(),
186 date: None,
187 content: String::new(),
188 hash: String::new(),
189 similarity_score: score,
190 }
191 }
192
193 #[tokio::test]
194 async fn dedups_by_note_keeping_first_and_preserves_order() {
195 let dir = TempDir::new().unwrap();
196 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
197
198 let chunks = vec![
200 chunk("/a.md", "A > Intro", 0.9),
201 chunk("/a.md", "A > Details", 0.7),
202 chunk("/b.md", "B", 0.5),
203 ];
204 let entries = chunks_to_entries(chunks, &vault);
205 assert_eq!(entries.len(), 2);
206 match (&entries[0], &entries[1]) {
207 (
208 FileListEntry::Note {
209 path: pa,
210 title: ta,
211 ..
212 },
213 FileListEntry::Note { path: pb, .. },
214 ) => {
215 assert_eq!(pa.to_string(), "/a.md");
216 assert_eq!(ta, "A > Intro"); assert_eq!(pb.to_string(), "/b.md");
218 }
219 _ => panic!("expected note entries"),
220 }
221 }
222}