BlueSky & more on desktop lazurite.stormlightlabs.org/
tauri rust typescript bluesky appview atproto solid
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add embeddings configuration and management commands

* add model dl UI and semantic search mode

+1835 -362
+1 -1
docs/designs/search.html
··· 151 151 </svg> 152 152 <input type="text" 153 153 value="at protocol architecture" 154 - placeholder="Search your saved posts..." 154 + placeholder="Search your saved & liked posts..." 155 155 class="w-full pl-12 pr-4 py-3 rounded-2xl text-base outline-none transition-all" 156 156 style="background: rgba(0,0,0,0.4); color: var(--on-surface);"> 157 157 <div class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-2">
+116 -3
src-tauri/Cargo.lock
··· 984 984 ] 985 985 986 986 [[package]] 987 + name = "console" 988 + version = "0.16.3" 989 + source = "registry+https://github.com/rust-lang/crates.io-index" 990 + checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" 991 + dependencies = [ 992 + "encode_unicode", 993 + "libc", 994 + "unicode-width 0.2.2", 995 + "windows-sys 0.61.2", 996 + ] 997 + 998 + [[package]] 987 999 name = "const-oid" 988 1000 version = "0.9.6" 989 1001 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1027 1039 source = "registry+https://github.com/rust-lang/crates.io-index" 1028 1040 checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" 1029 1041 dependencies = [ 1042 + "percent-encoding", 1030 1043 "time", 1031 1044 "version_check", 1032 1045 ] 1033 1046 1034 1047 [[package]] 1048 + name = "cookie_store" 1049 + version = "0.22.1" 1050 + source = "registry+https://github.com/rust-lang/crates.io-index" 1051 + checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" 1052 + dependencies = [ 1053 + "cookie", 1054 + "document-features", 1055 + "idna", 1056 + "indexmap 2.13.0", 1057 + "log", 1058 + "serde", 1059 + "serde_derive", 1060 + "serde_json", 1061 + "time", 1062 + "url", 1063 + ] 1064 + 1065 + [[package]] 1035 1066 name = "cordyceps" 1036 1067 version = "0.3.4" 1037 1068 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1603 1634 ] 1604 1635 1605 1636 [[package]] 1637 + name = "document-features" 1638 + version = "0.2.12" 1639 + source = "registry+https://github.com/rust-lang/crates.io-index" 1640 + checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" 1641 + dependencies = [ 1642 + "litrs", 1643 + ] 1644 + 1645 + [[package]] 1606 1646 name = "dom_query" 1607 1647 version = "0.27.0" 1608 1648 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1923 1963 checksum = "3688aa7e02113db24e0f83aba1edee912f36f515b52cffc9b3c550bbfc3eab87" 1924 1964 dependencies = [ 1925 1965 "anyhow", 1926 - "hf-hub", 1966 + "hf-hub 0.4.3", 1927 1967 "image", 1928 1968 "ndarray", 1929 1969 "ort", ··· 2127 2167 ] 2128 2168 2129 2169 [[package]] 2170 + name = "futures" 2171 + version = "0.3.32" 2172 + source = "registry+https://github.com/rust-lang/crates.io-index" 2173 + checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" 2174 + dependencies = [ 2175 + "futures-channel", 2176 + "futures-core", 2177 + "futures-executor", 2178 + "futures-io", 2179 + "futures-sink", 2180 + "futures-task", 2181 + "futures-util", 2182 + ] 2183 + 2184 + [[package]] 2130 2185 name = "futures-buffered" 2131 2186 version = "0.2.13" 2132 2187 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2146 2201 checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" 2147 2202 dependencies = [ 2148 2203 "futures-core", 2204 + "futures-sink", 2149 2205 ] 2150 2206 2151 2207 [[package]] ··· 2213 2269 source = "registry+https://github.com/rust-lang/crates.io-index" 2214 2270 checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" 2215 2271 dependencies = [ 2272 + "futures-channel", 2216 2273 "futures-core", 2217 2274 "futures-io", 2218 2275 "futures-macro", ··· 2775 2832 dependencies = [ 2776 2833 "dirs", 2777 2834 "http", 2778 - "indicatif", 2835 + "indicatif 0.17.11", 2779 2836 "libc", 2780 2837 "log", 2781 2838 "native-tls", ··· 2789 2846 ] 2790 2847 2791 2848 [[package]] 2849 + name = "hf-hub" 2850 + version = "0.5.0" 2851 + source = "registry+https://github.com/rust-lang/crates.io-index" 2852 + checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" 2853 + dependencies = [ 2854 + "dirs", 2855 + "futures", 2856 + "http", 2857 + "indicatif 0.18.4", 2858 + "libc", 2859 + "log", 2860 + "native-tls", 2861 + "num_cpus", 2862 + "rand 0.9.2", 2863 + "reqwest 0.12.28", 2864 + "serde", 2865 + "serde_json", 2866 + "thiserror 2.0.18", 2867 + "tokio", 2868 + "ureq 3.3.0", 2869 + "windows-sys 0.61.2", 2870 + ] 2871 + 2872 + [[package]] 2792 2873 name = "hickory-proto" 2793 2874 version = "0.24.4" 2794 2875 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3235 3316 source = "registry+https://github.com/rust-lang/crates.io-index" 3236 3317 checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" 3237 3318 dependencies = [ 3238 - "console", 3319 + "console 0.15.11", 3239 3320 "number_prefix", 3240 3321 "portable-atomic", 3241 3322 "unicode-width 0.2.2", ··· 3243 3324 ] 3244 3325 3245 3326 [[package]] 3327 + name = "indicatif" 3328 + version = "0.18.4" 3329 + source = "registry+https://github.com/rust-lang/crates.io-index" 3330 + checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" 3331 + dependencies = [ 3332 + "console 0.16.3", 3333 + "portable-atomic", 3334 + "unicode-width 0.2.2", 3335 + "unit-prefix", 3336 + "web-time", 3337 + ] 3338 + 3339 + [[package]] 3246 3340 name = "infer" 3247 3341 version = "0.19.0" 3248 3342 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3757 3851 version = "0.1.0" 3758 3852 dependencies = [ 3759 3853 "fastembed", 3854 + "hf-hub 0.5.0", 3760 3855 "jacquard", 3761 3856 "reqwest 0.12.28", 3762 3857 "rusqlite", ··· 3891 3986 version = "0.8.1" 3892 3987 source = "registry+https://github.com/rust-lang/crates.io-index" 3893 3988 checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" 3989 + 3990 + [[package]] 3991 + name = "litrs" 3992 + version = "1.0.0" 3993 + source = "registry+https://github.com/rust-lang/crates.io-index" 3994 + checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" 3894 3995 3895 3996 [[package]] 3896 3997 name = "lock_api" ··· 8124 8225 checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" 8125 8226 8126 8227 [[package]] 8228 + name = "unit-prefix" 8229 + version = "0.5.2" 8230 + source = "registry+https://github.com/rust-lang/crates.io-index" 8231 + checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" 8232 + 8233 + [[package]] 8127 8234 name = "unsigned-varint" 8128 8235 version = "0.8.0" 8129 8236 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 8162 8269 checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" 8163 8270 dependencies = [ 8164 8271 "base64 0.22.1", 8272 + "cookie_store", 8165 8273 "der 0.8.0", 8274 + "flate2", 8166 8275 "log", 8167 8276 "native-tls", 8168 8277 "percent-encoding", 8278 + "rustls", 8169 8279 "rustls-pki-types", 8280 + "serde", 8281 + "serde_json", 8170 8282 "socks", 8171 8283 "ureq-proto", 8172 8284 "utf8-zero", 8173 8285 "webpki-root-certs", 8286 + "webpki-roots 1.0.6", 8174 8287 ] 8175 8288 8176 8289 [[package]]
+1
src-tauri/Cargo.toml
··· 29 29 jacquard = "0.11.0" 30 30 sqlite-vec = "0.1.7" 31 31 fastembed = "5.13.0" 32 + hf-hub = "0.5" 32 33 tokio = "1.50.0" 33 34 tauri-plugin-deep-link = "2" 34 35 tauri-plugin-log = "2"
+18 -1
src-tauri/src/commands/search.rs
··· 17 17 pub fn search_posts( 18 18 query: String, mode: String, limit: u32, app: AppHandle, state: State<'_, AppState>, 19 19 ) -> Result<Vec<PostResult>, AppError> { 20 - search::search_posts(query, mode, limit, &app, &state) 20 + search::search_posts(&query, &mode, limit, &app, &state) 21 21 } 22 22 23 23 #[tauri::command] ··· 58 58 pub fn set_embeddings_enabled(enabled: bool, state: State<'_, AppState>) -> Result<(), AppError> { 59 59 search::set_embeddings_enabled(enabled, &state) 60 60 } 61 + 62 + #[tauri::command] 63 + pub fn get_embeddings_enabled(state: State<'_, AppState>) -> Result<bool, AppError> { 64 + search::get_embeddings_enabled(&state) 65 + } 66 + 67 + #[tauri::command] 68 + pub fn get_embeddings_config(app: AppHandle, state: State<'_, AppState>) -> Result<search::EmbeddingsConfig, AppError> { 69 + search::get_embeddings_config(&app, &state) 70 + } 71 + 72 + #[tauri::command] 73 + pub fn prepare_embeddings_model( 74 + app: AppHandle, state: State<'_, AppState>, 75 + ) -> Result<search::EmbeddingsConfig, AppError> { 76 + search::prepare_embeddings_model(&app, &state) 77 + }
+4 -1
src-tauri/src/lib.rs
··· 108 108 cmd::search::get_sync_status, 109 109 cmd::search::embed_pending_posts, 110 110 cmd::search::reindex_embeddings, 111 - cmd::search::set_embeddings_enabled 111 + cmd::search::set_embeddings_enabled, 112 + cmd::search::get_embeddings_enabled, 113 + cmd::search::get_embeddings_config, 114 + cmd::search::prepare_embeddings_model 112 115 ]) 113 116 .run(tauri::generate_context!()) 114 117 .expect("error while running tauri application");
+395 -63
src-tauri/src/search.rs
··· 2 2 use super::error::{AppError, Result}; 3 3 use super::state::AppState; 4 4 use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions}; 5 + use hf_hub::api::{sync::ApiBuilder, Progress}; 6 + use hf_hub::Cache; 5 7 use jacquard::api::app_bsky::actor::search_actors::SearchActors; 8 + use jacquard::api::app_bsky::bookmark::get_bookmarks::GetBookmarks; 6 9 use jacquard::api::app_bsky::feed::get_actor_likes::GetActorLikes; 7 10 use jacquard::api::app_bsky::feed::search_posts::SearchPosts; 8 11 use jacquard::api::app_bsky::graph::search_starter_packs::SearchStarterPacks; ··· 12 15 use rusqlite::{params, Connection, OptionalExtension}; 13 16 use serde::Serialize; 14 17 use std::collections::HashMap; 15 - use std::path::PathBuf; 16 - use std::sync::Arc; 18 + use std::path::{Path, PathBuf}; 19 + use std::sync::{Arc, LazyLock, Mutex}; 17 20 use std::time::{Duration, Instant}; 18 21 use tauri::{AppHandle, Manager}; 19 22 use tauri_plugin_log::log; 20 23 21 24 const DEFAULT_RRF_K: f64 = 60.0; 25 + const EMBEDDING_MODEL_NAME: &str = "nomic-embed-text-v1.5"; 26 + const EMBEDDING_MODEL_REPO: &str = "nomic-ai/nomic-embed-text-v1.5"; 27 + const EMBEDDING_MODEL_FILE: &str = "onnx/model.onnx"; 28 + const EMBEDDING_TOKENIZER_FILES: &[&str] = &[ 29 + "config.json", 30 + "special_tokens_map.json", 31 + "tokenizer.json", 32 + "tokenizer_config.json", 33 + ]; 34 + const EMBEDDING_DIMENSIONS: i64 = 768; 22 35 const SEARCH_SYNC_CHECK_INTERVAL: Duration = Duration::from_secs(5); 23 36 const SEARCH_SYNC_INTERVAL: Duration = Duration::from_secs(15 * 60); 37 + static EMBEDDINGS_DOWNLOAD_STATE: LazyLock<Mutex<EmbeddingsDownloadState>> = 38 + LazyLock::new(|| Mutex::new(EmbeddingsDownloadState::default())); 24 39 25 40 #[derive(Debug, Serialize)] 26 41 #[serde(rename_all = "camelCase")] 27 42 pub struct SyncStatus { 43 + pub did: String, 28 44 pub source: String, 29 45 pub post_count: i64, 30 46 pub cursor: Option<String>, ··· 42 58 pub created_at: Option<String>, 43 59 pub source: String, 44 60 pub score: f64, 61 + pub keyword_match: bool, 62 + pub semantic_match: bool, 45 63 } 46 64 47 65 #[derive(Clone, Copy, Debug, PartialEq, Eq)] ··· 57 75 post: PostResult, 58 76 } 59 77 78 + #[derive(Clone, Debug, Default)] 79 + struct EmbeddingsDownloadState { 80 + active: bool, 81 + current_file: Option<String>, 82 + downloaded_files: usize, 83 + total_files: usize, 84 + current_bytes: usize, 85 + current_total_bytes: usize, 86 + started_at: Option<Instant>, 87 + last_error: Option<String>, 88 + } 89 + 90 + struct ModelDownloadProgress { 91 + file_index: usize, 92 + total_files: usize, 93 + } 94 + 95 + impl ModelDownloadProgress { 96 + fn new(file_index: usize, total_files: usize) -> Self { 97 + Self { file_index, total_files } 98 + } 99 + } 100 + 101 + impl Progress for ModelDownloadProgress { 102 + fn init(&mut self, size: usize, filename: &str) { 103 + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { 104 + state.active = true; 105 + state.current_file = Some(filename.to_owned()); 106 + state.downloaded_files = self.file_index; 107 + state.total_files = self.total_files; 108 + state.current_bytes = 0; 109 + state.current_total_bytes = size; 110 + state.started_at = Some(Instant::now()); 111 + state.last_error = None; 112 + } 113 + } 114 + 115 + fn update(&mut self, size: usize) { 116 + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { 117 + state.current_bytes = state.current_bytes.saturating_add(size); 118 + } 119 + } 120 + 121 + fn finish(&mut self) { 122 + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { 123 + state.downloaded_files = self.file_index + 1; 124 + state.current_bytes = state.current_total_bytes; 125 + } 126 + } 127 + } 128 + 60 129 fn validate_query(query: &str) -> Result<()> { 61 130 if query.trim().is_empty() { 62 131 return Err(AppError::validation("search query must not be empty")); ··· 159 228 Ok(()) 160 229 } 161 230 231 + fn db_post_exists(conn: &Connection, storage_key: &str) -> Result<bool> { 232 + conn.query_row( 233 + "SELECT 1 FROM posts WHERE storage_key = ?1", 234 + params![storage_key], 235 + |_| Ok(()), 236 + ) 237 + .optional() 238 + .map(|row| row.is_some()) 239 + .map_err(AppError::from) 240 + } 241 + 162 242 /// Upsert a single `FeedViewPost` JSON item into the `posts` table. 163 243 /// On conflict (same uri) updates mutable fields but keeps indexed_at. 164 - fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Value, source: &str) -> Result<()> { 165 - let post = feed_item.get("post").unwrap_or(feed_item); 166 - 244 + fn db_upsert_post_value(conn: &Connection, owner_did: &str, post: &serde_json::Value, source: &str) -> Result<bool> { 167 245 let uri = post 168 246 .get("uri") 169 247 .and_then(|v| v.as_str()) ··· 186 264 let created_at = record.and_then(|r| r.get("createdAt")).and_then(|v| v.as_str()); 187 265 let json_record = record.map(|r| r.to_string()); 188 266 let storage_key = storage_key(owner_did, source, uri); 267 + let inserted = !db_post_exists(conn, &storage_key)?; 189 268 190 269 conn.execute( 191 270 "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, author_handle, text, created_at, json_record, source) ··· 209 288 source 210 289 ], 211 290 )?; 212 - Ok(()) 291 + Ok(inserted) 292 + } 293 + 294 + fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Value, source: &str) -> Result<bool> { 295 + let post = feed_item.get("post").unwrap_or(feed_item); 296 + let kind = post.get("$type").and_then(|value| value.as_str()); 297 + match kind { 298 + Some("app.bsky.feed.defs#blockedPost" | "app.bsky.feed.defs#notFoundPost") => Ok(true), 299 + _ => db_upsert_post_value(conn, owner_did, post, source), 300 + } 301 + } 302 + 303 + fn db_upsert_bookmark(conn: &Connection, owner_did: &str, bookmark: &serde_json::Value) -> Result<bool> { 304 + let item = bookmark 305 + .get("item") 306 + .ok_or_else(|| AppError::validation("bookmark item missing item payload"))?; 307 + let kind = item.get("$type").and_then(|value| value.as_str()); 308 + match kind { 309 + Some("app.bsky.feed.defs#blockedPost" | "app.bsky.feed.defs#notFoundPost") => Ok(true), 310 + _ => db_upsert_post_value(conn, owner_did, item, "bookmark"), 311 + } 213 312 } 214 313 215 314 fn db_post_count(conn: &Connection, owner_did: &str, source: &str) -> Result<i64> { ··· 232 331 .optional()? 233 332 .unwrap_or((None, None)); 234 333 235 - Ok(SyncStatus { source: source.to_owned(), post_count, cursor, last_synced_at }) 334 + Ok(SyncStatus { did: did.to_owned(), source: source.to_owned(), post_count, cursor, last_synced_at }) 236 335 } 237 336 238 337 pub async fn search_posts_network( ··· 322 421 323 422 /// Sync the authenticated user's likes (or bookmarks) into the local DB. 324 423 /// 325 - /// Resumes from the last stored cursor so interrupted syncs never re-fetch the full history. 326 - /// On completion the cursor is cleared, allowing subsequent calls to pick up new items from the top of the feed. 424 + /// Resumes from the last stored cursor if a previous sync was interrupted. 425 + /// During a fresh sync pass, we stop once we hit already-indexed items so we do not re-fetch the full history. 327 426 pub async fn sync_posts(did: String, source: String, state: &AppState) -> Result<SyncStatus> { 328 427 validate_source(&source)?; 329 - 330 - if source == "bookmark" { 331 - return Err(AppError::validation("bookmark sync is not yet supported")); 332 - } 333 - 334 428 let session = get_session(state).await?; 335 429 336 430 let mut cursor: Option<String> = { 337 431 let conn = state.auth_store.lock_connection()?; 338 432 db_load_sync_cursor(&conn, &did, &source)? 339 433 }; 434 + let resuming = cursor.is_some(); 340 435 341 436 log::info!("starting {source} sync for {did}, resume cursor: {cursor:?}"); 342 437 343 438 loop { 344 - let output = session 345 - .send( 346 - GetActorLikes::new() 347 - .limit(Some(100i64)) 348 - .cursor(cursor.as_deref().map(|c| c.into())) 349 - .actor(AtIdentifier::Did(Did::new(&did)?)) 350 - .build(), 351 - ) 352 - .await 353 - .map_err(|error| { 354 - log::error!("getActorLikes error: {error}"); 355 - AppError::validation("getActorLikes error") 356 - })? 357 - .into_output() 358 - .map_err(|error| { 359 - log::error!("getActorLikes output error: {error}"); 360 - AppError::validation("getActorLikes output error") 361 - })?; 439 + let (items, next_cursor) = match source.as_str() { 440 + "like" => { 441 + let output = session 442 + .send( 443 + GetActorLikes::new() 444 + .limit(Some(100i64)) 445 + .cursor(cursor.as_deref().map(|value| value.into())) 446 + .actor(AtIdentifier::Did(Did::new(&did)?)) 447 + .build(), 448 + ) 449 + .await 450 + .map_err(|error| { 451 + log::error!("getActorLikes error: {error}"); 452 + AppError::validation("getActorLikes error") 453 + })? 454 + .into_output() 455 + .map_err(|error| { 456 + log::error!("getActorLikes output error: {error}"); 457 + AppError::validation("getActorLikes output error") 458 + })?; 459 + let output_json = serde_json::to_value(&output)?; 460 + let feed = output_json 461 + .get("feed") 462 + .and_then(|value| value.as_array()) 463 + .cloned() 464 + .unwrap_or_default(); 465 + let next = output_json 466 + .get("cursor") 467 + .and_then(|value| value.as_str()) 468 + .map(str::to_owned); 469 + (feed, next) 470 + } 471 + "bookmark" => { 472 + let output = session 473 + .send( 474 + GetBookmarks::new() 475 + .limit(Some(100i64)) 476 + .cursor(cursor.as_deref().map(|value| value.into())) 477 + .build(), 478 + ) 479 + .await 480 + .map_err(|error| { 481 + log::error!("getBookmarks error: {error}"); 482 + AppError::validation("getBookmarks error") 483 + })? 484 + .into_output() 485 + .map_err(|error| { 486 + log::error!("getBookmarks output error: {error}"); 487 + AppError::validation("getBookmarks output error") 488 + })?; 489 + let output_json = serde_json::to_value(&output)?; 490 + let bookmarks = output_json 491 + .get("bookmarks") 492 + .and_then(|value| value.as_array()) 493 + .cloned() 494 + .unwrap_or_default(); 495 + let next = output_json 496 + .get("cursor") 497 + .and_then(|value| value.as_str()) 498 + .map(str::to_owned); 499 + (bookmarks, next) 500 + } 501 + _ => unreachable!(), 502 + }; 362 503 363 - let output_json = serde_json::to_value(&output)?; 504 + { 505 + let conn = state.auth_store.lock_connection()?; 506 + if items.is_empty() { 507 + db_save_sync_state(&conn, &did, &source, None)?; 508 + log::info!("{source} sync for {did}: empty page, stopping"); 509 + break; 510 + } 364 511 365 - let feed = output_json 366 - .get("feed") 367 - .and_then(|v| v.as_array()) 368 - .cloned() 369 - .unwrap_or_default(); 512 + let mut inserted_count = 0usize; 513 + let mut existing_count = 0usize; 370 514 371 - if feed.is_empty() { 372 - log::info!("{source} sync for {did}: empty page, stopping"); 373 - break; 374 - } 515 + for item in &items { 516 + let inserted = match source.as_str() { 517 + "like" => db_upsert_post(&conn, &did, item, &source)?, 518 + "bookmark" => db_upsert_bookmark(&conn, &did, item)?, 519 + _ => unreachable!(), 520 + }; 375 521 376 - let next_cursor = output_json.get("cursor").and_then(|v| v.as_str()).map(str::to_owned); 522 + if inserted { 523 + inserted_count += 1; 524 + } else { 525 + existing_count += 1; 526 + } 527 + } 528 + 529 + let stop_after_page = !resuming && existing_count > 0; 530 + let cursor_to_store = if stop_after_page { None } else { next_cursor.as_deref() }; 531 + db_save_sync_state(&conn, &did, &source, cursor_to_store)?; 532 + 533 + log::debug!( 534 + "{source} sync for {did}: processed {} item(s), inserted {}, existing {}, next cursor: {next_cursor:?}", 535 + items.len(), 536 + inserted_count, 537 + existing_count 538 + ); 377 539 378 - { 379 - let conn = state.auth_store.lock_connection()?; 380 - for item in &feed { 381 - db_upsert_post(&conn, &did, item, &source)?; 540 + if stop_after_page { 541 + log::info!("{source} sync for {did}: reached previously indexed items, stopping"); 542 + break; 382 543 } 383 - db_save_sync_state(&conn, &did, &source, next_cursor.as_deref())?; 384 544 } 385 545 386 - log::debug!( 387 - "{source} sync for {did}: upserted {} posts, next cursor: {next_cursor:?}", 388 - feed.len() 389 - ); 390 - 391 546 match next_cursor { 392 547 None => { 393 548 log::info!("{source} sync for {did}: reached end of feed"); 394 549 break; 395 550 } 396 - Some(c) => cursor = Some(c), 551 + Some(next) => cursor = Some(next), 397 552 } 398 553 } 399 554 ··· 413 568 const EMBED_BATCH_SIZE: usize = 32; 414 569 415 570 fn build_embedding_model(models_dir: PathBuf) -> Result<TextEmbedding> { 571 + ensure_model_downloaded(&models_dir)?; 416 572 TextEmbedding::try_new( 417 573 TextInitOptions::new(EmbeddingModel::NomicEmbedTextV15) 418 574 .with_cache_dir(models_dir) ··· 431 587 Ok(dir) 432 588 } 433 589 590 + fn required_embedding_files() -> Vec<&'static str> { 591 + let mut files = vec![EMBEDDING_MODEL_FILE]; 592 + files.extend(EMBEDDING_TOKENIZER_FILES); 593 + files 594 + } 595 + 596 + fn cached_embedding_files(models_dir: &Path) -> usize { 597 + let cache = Cache::new(models_dir.to_path_buf()); 598 + let repo = cache.model(EMBEDDING_MODEL_REPO.to_owned()); 599 + required_embedding_files() 600 + .into_iter() 601 + .filter(|filename| repo.get(filename).is_some()) 602 + .count() 603 + } 604 + 605 + fn embeddings_downloaded(models_dir: &Path) -> bool { 606 + cached_embedding_files(models_dir) == required_embedding_files().len() 607 + } 608 + 609 + fn set_download_idle_state(downloaded_files: usize, total_files: usize) { 610 + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { 611 + state.active = false; 612 + state.current_file = None; 613 + state.downloaded_files = downloaded_files; 614 + state.total_files = total_files; 615 + state.current_bytes = 0; 616 + state.current_total_bytes = 0; 617 + state.started_at = None; 618 + state.last_error = None; 619 + } 620 + } 621 + 622 + fn set_download_error(message: String) { 623 + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { 624 + state.active = false; 625 + state.current_file = None; 626 + state.current_bytes = 0; 627 + state.current_total_bytes = 0; 628 + state.started_at = None; 629 + state.last_error = Some(message); 630 + } 631 + } 632 + 633 + fn ensure_model_downloaded(models_dir: &Path) -> Result<()> { 634 + let required_files = required_embedding_files(); 635 + let total_files = required_files.len(); 636 + let already_cached = cached_embedding_files(models_dir); 637 + if already_cached == total_files { 638 + set_download_idle_state(total_files, total_files); 639 + return Ok(()); 640 + } 641 + 642 + set_download_idle_state(already_cached, total_files); 643 + 644 + let api = ApiBuilder::new() 645 + .with_cache_dir(models_dir.to_path_buf()) 646 + .with_progress(false) 647 + .build() 648 + .map_err(|error| AppError::validation(format!("failed to initialize embeddings downloader: {error}")))?; 649 + let repo = api.model(EMBEDDING_MODEL_REPO.to_owned()); 650 + let cache = Cache::new(models_dir.to_path_buf()); 651 + let cache_repo = cache.model(EMBEDDING_MODEL_REPO.to_owned()); 652 + 653 + for (index, filename) in required_files.iter().enumerate() { 654 + if cache_repo.get(filename).is_some() { 655 + set_download_idle_state(index + 1, total_files); 656 + continue; 657 + } 658 + 659 + let download = repo.download_with_progress(filename, ModelDownloadProgress::new(index, total_files)); 660 + if let Err(error) = download { 661 + let message = format!("failed to download embeddings file {filename}: {error}"); 662 + set_download_error(message.clone()); 663 + return Err(AppError::validation(message)); 664 + } 665 + } 666 + 667 + set_download_idle_state(total_files, total_files); 668 + Ok(()) 669 + } 670 + 434 671 fn db_get_embeddings_enabled(conn: &Connection) -> Result<bool> { 435 672 let val: Option<String> = conn 436 673 .query_row( ··· 521 758 created_at: row.get(6)?, 522 759 source: row.get(7)?, 523 760 score: -raw_rank, 761 + keyword_match: true, 762 + semantic_match: false, 524 763 }, 525 764 }) 526 765 } ··· 538 777 created_at: row.get(6)?, 539 778 source: row.get(7)?, 540 779 score: 1.0 / (1.0 + distance), 780 + keyword_match: false, 781 + semantic_match: true, 541 782 }, 542 783 }) 543 784 } ··· 567 808 .entry(row.storage_key.clone()) 568 809 .and_modify(|value| *value += score) 569 810 .or_insert(score); 570 - fused.entry(row.storage_key.clone()).or_insert(row); 811 + fused 812 + .entry(row.storage_key.clone()) 813 + .and_modify(|existing| { 814 + existing.post.keyword_match |= row.post.keyword_match; 815 + existing.post.semantic_match |= row.post.semantic_match; 816 + }) 817 + .or_insert(row); 571 818 } 572 819 } 573 820 ··· 699 946 Ok(total) 700 947 } 701 948 702 - pub fn search_posts( 703 - query: String, mode: String, limit: u32, app: &AppHandle, state: &AppState, 704 - ) -> Result<Vec<PostResult>> { 949 + pub fn search_posts(query: &str, mode: &str, limit: u32, app: &AppHandle, state: &AppState) -> Result<Vec<PostResult>> { 705 950 validate_query(&query)?; 706 951 let limit = validate_limit(limit)?; 707 952 let mode = validate_search_mode(&mode)?; ··· 783 1028 db_set_embeddings_enabled(&conn, enabled) 784 1029 } 785 1030 1031 + /// Get the current embeddings-enabled preference. 1032 + pub fn get_embeddings_enabled(state: &AppState) -> Result<bool> { 1033 + let conn = state.auth_store.lock_connection()?; 1034 + db_get_embeddings_enabled(&conn) 1035 + } 1036 + 1037 + #[derive(Clone, Debug, Serialize)] 1038 + #[serde(rename_all = "camelCase")] 1039 + pub struct EmbeddingsConfig { 1040 + pub enabled: bool, 1041 + pub model_name: String, 1042 + pub dimensions: i64, 1043 + pub downloaded: bool, 1044 + pub download_active: bool, 1045 + pub download_progress: Option<f64>, 1046 + pub download_eta_seconds: Option<u64>, 1047 + pub download_file: Option<String>, 1048 + pub download_file_index: Option<usize>, 1049 + pub download_file_total: Option<usize>, 1050 + pub last_error: Option<String>, 1051 + } 1052 + 1053 + /// Get the embeddings configuration. 1054 + pub fn get_embeddings_config(app: &AppHandle, state: &AppState) -> Result<EmbeddingsConfig> { 1055 + let conn = state.auth_store.lock_connection()?; 1056 + let enabled = db_get_embeddings_enabled(&conn)?; 1057 + let models_dir = resolve_models_dir(app)?; 1058 + let downloaded = embeddings_downloaded(&models_dir); 1059 + let state = EMBEDDINGS_DOWNLOAD_STATE 1060 + .lock() 1061 + .map_err(|_| AppError::StatePoisoned("embeddings_download_state"))?; 1062 + let download_progress = if state.active && state.current_total_bytes > 0 { 1063 + Some((state.current_bytes as f64 / state.current_total_bytes as f64) * 100.0) 1064 + } else if downloaded { 1065 + Some(100.0) 1066 + } else { 1067 + None 1068 + }; 1069 + let download_eta_seconds = if state.active { 1070 + state.started_at.and_then(|started_at| { 1071 + let elapsed = started_at.elapsed().as_secs_f64(); 1072 + let current = state.current_bytes as f64; 1073 + let total = state.current_total_bytes as f64; 1074 + if elapsed <= 0.0 || current <= 0.0 || total <= current { 1075 + None 1076 + } else { 1077 + let bytes_per_second = current / elapsed; 1078 + let remaining = total - current; 1079 + Some((remaining / bytes_per_second).ceil() as u64) 1080 + } 1081 + }) 1082 + } else { 1083 + None 1084 + }; 1085 + 1086 + Ok(EmbeddingsConfig { 1087 + enabled, 1088 + model_name: EMBEDDING_MODEL_NAME.to_string(), 1089 + dimensions: EMBEDDING_DIMENSIONS, 1090 + downloaded, 1091 + download_active: state.active, 1092 + download_progress, 1093 + download_eta_seconds, 1094 + download_file: state.current_file.clone(), 1095 + download_file_index: state.active.then_some(state.downloaded_files + 1), 1096 + download_file_total: (state.total_files > 0).then_some(state.total_files), 1097 + last_error: state.last_error.clone(), 1098 + }) 1099 + } 1100 + 1101 + pub fn prepare_embeddings_model(app: &AppHandle, state: &AppState) -> Result<EmbeddingsConfig> { 1102 + let enabled = { 1103 + let conn = state.auth_store.lock_connection()?; 1104 + db_get_embeddings_enabled(&conn)? 1105 + }; 1106 + 1107 + if enabled { 1108 + let models_dir = resolve_models_dir(app)?; 1109 + ensure_model_downloaded(&models_dir)?; 1110 + } 1111 + 1112 + get_embeddings_config(app, state) 1113 + } 1114 + 786 1115 fn sync_due(active_did: Option<&str>, last_synced_did: Option<&str>, last_synced_at: Option<Instant>) -> bool { 787 1116 match active_did { 788 1117 None => false, ··· 820 1149 821 1150 if sync_due(active_did.as_deref(), last_synced_did.as_deref(), last_synced_at) { 822 1151 let did = active_did.clone().unwrap_or_default(); 823 - match sync_posts(did.clone(), "like".to_owned(), &state).await { 824 - Ok(status) => { 1152 + let like_sync = sync_posts(did.clone(), "like".to_owned(), &state).await; 1153 + let bookmark_sync = sync_posts(did.clone(), "bookmark".to_owned(), &state).await; 1154 + match (like_sync, bookmark_sync) { 1155 + (Ok(like_status), Ok(bookmark_status)) => { 825 1156 log::info!( 826 - "background search sync complete for {} likes: {} post(s)", 1157 + "background search sync complete for {} likes/bookmarks: {}/{} post(s)", 827 1158 did, 828 - status.post_count 1159 + like_status.post_count, 1160 + bookmark_status.post_count 829 1161 ); 830 1162 if let Err(error) = embed_pending_posts(&app, &state) { 831 1163 log::warn!("background embedding pass failed for {did}: {error}"); ··· 833 1165 last_synced_did = Some(did); 834 1166 last_synced_at = Some(Instant::now()); 835 1167 } 836 - Err(error) => { 1168 + (Err(error), _) | (_, Err(error)) => { 837 1169 log::warn!("background search sync failed: {error}"); 838 1170 } 839 1171 }
+1 -1
src/App.tsx
··· 332 332 activeHandle={session.handle} 333 333 onError={(message) => setApp("errorMessage", message)} /> 334 334 )} 335 - renderTimeline={(session, context) => ( 335 + renderTimeline={({ session, context }) => ( 336 336 <FeedWorkspace 337 337 activeAvatar={activeAccount()?.avatar} 338 338 activeSession={session}
+1 -1
src/components/AppRail.tsx
··· 40 40 href="/notifications" 41 41 label="Notifications" 42 42 icon="notifications" /> 43 - <RailButton end compact={props.collapsed} href="/explorer" label="Explorer" icon="explorer" /> 43 + <RailButton end compact={props.collapsed} href="/explorer" label="AT Explorer" icon="explorer" /> 44 44 </Show> 45 45 </div> 46 46 );
+154
src/components/search/EmbeddingsSettings.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { EmbeddingsSettings } from "./EmbeddingsSettings"; 4 + 5 + const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); 6 + const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); 7 + const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); 8 + 9 + vi.mock( 10 + "$/lib/api/search", 11 + () => ({ 12 + getEmbeddingsConfig: getEmbeddingsConfigMock, 13 + prepareEmbeddingsModel: prepareEmbeddingsModelMock, 14 + setEmbeddingsEnabled: setEmbeddingsEnabledMock, 15 + }), 16 + ); 17 + 18 + vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 19 + 20 + describe("EmbeddingsSettings", () => { 21 + beforeEach(() => { 22 + getEmbeddingsConfigMock.mockReset(); 23 + prepareEmbeddingsModelMock.mockReset(); 24 + setEmbeddingsEnabledMock.mockReset(); 25 + 26 + getEmbeddingsConfigMock.mockResolvedValue({ 27 + enabled: true, 28 + modelName: "nomic-embed-text-v1.5", 29 + dimensions: 768, 30 + downloaded: true, 31 + downloadActive: false, 32 + }); 33 + prepareEmbeddingsModelMock.mockResolvedValue({ 34 + enabled: true, 35 + modelName: "nomic-embed-text-v1.5", 36 + dimensions: 768, 37 + downloaded: true, 38 + downloadActive: false, 39 + }); 40 + setEmbeddingsEnabledMock.mockResolvedValue(void 0); 41 + }); 42 + 43 + it("renders embeddings settings with model info", async () => { 44 + render(() => <EmbeddingsSettings />); 45 + 46 + expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); 47 + expect(await screen.findByText(/nomic-embed-text-v1\.5/)).toBeInTheDocument(); 48 + expect(await screen.findByText(/768D/)).toBeInTheDocument(); 49 + }); 50 + 51 + it("shows toggle in enabled state when embeddings are enabled", async () => { 52 + render(() => <EmbeddingsSettings />); 53 + 54 + const toggle = await screen.findByRole("switch"); 55 + expect(toggle).toHaveAttribute("aria-checked", "true"); 56 + }); 57 + 58 + it("shows toggle in disabled state when embeddings are disabled", async () => { 59 + getEmbeddingsConfigMock.mockResolvedValue({ 60 + enabled: false, 61 + modelName: "nomic-embed-text-v1.5", 62 + dimensions: 768, 63 + downloaded: false, 64 + downloadActive: false, 65 + }); 66 + 67 + render(() => <EmbeddingsSettings />); 68 + 69 + const toggle = await screen.findByRole("switch"); 70 + expect(toggle).toHaveAttribute("aria-checked", "false"); 71 + }); 72 + 73 + it("toggles embeddings when clicking the switch", async () => { 74 + getEmbeddingsConfigMock.mockResolvedValueOnce({ 75 + enabled: true, 76 + modelName: "nomic-embed-text-v1.5", 77 + dimensions: 768, 78 + downloaded: true, 79 + downloadActive: false, 80 + }).mockResolvedValueOnce({ 81 + enabled: false, 82 + modelName: "nomic-embed-text-v1.5", 83 + dimensions: 768, 84 + downloaded: false, 85 + downloadActive: false, 86 + }); 87 + 88 + render(() => <EmbeddingsSettings />); 89 + 90 + const toggle = await screen.findByRole("switch"); 91 + expect(toggle).toHaveAttribute("aria-checked", "true"); 92 + 93 + fireEvent.click(toggle); 94 + 95 + await waitFor(() => { 96 + expect(setEmbeddingsEnabledMock).toHaveBeenCalledWith(false); 97 + }); 98 + 99 + await waitFor(() => { 100 + expect(toggle).toHaveAttribute("aria-checked", "false"); 101 + }); 102 + }); 103 + 104 + it("shows download progress when model is not downloaded", async () => { 105 + getEmbeddingsConfigMock.mockResolvedValue({ 106 + enabled: true, 107 + modelName: "nomic-embed-text-v1.5", 108 + dimensions: 768, 109 + downloaded: false, 110 + downloadActive: true, 111 + downloadProgress: 0, 112 + downloadFile: "onnx/model.onnx", 113 + downloadFileIndex: 1, 114 + downloadFileTotal: 5, 115 + }); 116 + 117 + render(() => <EmbeddingsSettings />); 118 + 119 + expect(await screen.findAllByText(/downloading model files/i)).toHaveLength(2); 120 + expect(await screen.findByText(/0%/)).toBeInTheDocument(); 121 + }); 122 + 123 + it("displays semantic search description", async () => { 124 + render(() => <EmbeddingsSettings />); 125 + expect(await screen.findByText(/conceptually similar posts/i)).toBeInTheDocument(); 126 + }); 127 + 128 + it("handles errors when loading config gracefully", async () => { 129 + getEmbeddingsConfigMock.mockRejectedValue(new Error("Failed to load")); 130 + 131 + render(() => <EmbeddingsSettings />); 132 + 133 + // Should still render without crashing 134 + await waitFor(() => { 135 + expect(getEmbeddingsConfigMock).toHaveBeenCalled(); 136 + }); 137 + }); 138 + 139 + it("handles errors when toggling gracefully", async () => { 140 + setEmbeddingsEnabledMock.mockRejectedValue(new Error("Failed to save")); 141 + 142 + render(() => <EmbeddingsSettings />); 143 + 144 + const toggle = await screen.findByRole("switch"); 145 + fireEvent.click(toggle); 146 + 147 + await waitFor(() => { 148 + expect(setEmbeddingsEnabledMock).toHaveBeenCalled(); 149 + }); 150 + 151 + // Toggle state should remain unchanged on error 152 + expect(toggle).toHaveAttribute("aria-checked", "true"); 153 + }); 154 + });
+289
src/components/search/EmbeddingsSettings.tsx
··· 1 + /* eslint react/jsx-max-depth: ["error", { "max": 5 }] */ 2 + import { Icon } from "$/components/shared/Icon"; 3 + import type { EmbeddingsConfig } from "$/lib/api/search"; 4 + import { getEmbeddingsConfig, prepareEmbeddingsModel, setEmbeddingsEnabled } from "$/lib/api/search"; 5 + import { formatEtaSeconds, formatProgress } from "$/lib/utils/text"; 6 + import * as logger from "@tauri-apps/plugin-log"; 7 + import { createEffect, createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 8 + import { Motion, Presence } from "solid-motionone"; 9 + 10 + function ModelDescriptor(props: { config: EmbeddingsConfig | null }) { 11 + return ( 12 + <p class="m-0 text-xs text-on-surface-variant flex items-center gap-2"> 13 + <span>{props.config?.modelName ?? "nomic-embed-text-v1.5"}</span> 14 + <span>·</span> 15 + <span>{props.config?.dimensions ?? 768}D</span> 16 + </p> 17 + ); 18 + } 19 + 20 + function EmbedSettingsHeader(props: { config: EmbeddingsConfig | null; isLoading: boolean; handleToggle: () => void }) { 21 + return ( 22 + <div class="flex items-center gap-4 justify-between"> 23 + <div class="flex gap-2 items-center"> 24 + <Icon 25 + kind="search" 26 + class="text-lg text-primary h-11 w-11 items-center justify-center rounded-full bg-primary/15" /> 27 + 28 + <p class="text-lg font-medium text-on-surface">Semantic Search</p> 29 + </div> 30 + 31 + <Show when={props.config}> 32 + {(current) => ( 33 + <ToggleSwitch 34 + checked={current().enabled} 35 + disabled={props.isLoading || current().downloadActive} 36 + onChange={() => void props.handleToggle()} /> 37 + )} 38 + </Show> 39 + </div> 40 + ); 41 + } 42 + 43 + function DownloadButton(props: { config: EmbeddingsConfig | null; prepareModel: () => Promise<void> }) { 44 + return ( 45 + <button 46 + type="button" 47 + onClick={() => void props.prepareModel()} 48 + class="inline-flex items-center justify-center gap-2 rounded-xl border-0 bg-white/8 px-3 py-2 text-xs font-medium text-on-surface transition hover:bg-white/12"> 49 + <Icon kind="download" /> 50 + <Show when={props.config?.lastError} fallback="Prepare model">Retry download</Show> 51 + </button> 52 + ); 53 + } 54 + 55 + function ToggleSwitch(props: { checked: boolean; disabled?: boolean; onChange: () => void }) { 56 + return ( 57 + <button 58 + type="button" 59 + role="switch" 60 + aria-checked={props.checked} 61 + disabled={props.disabled} 62 + onClick={() => props.onChange()} 63 + class="relative inline-flex h-6 w-10 items-center rounded-full transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50" 64 + classList={{ "bg-primary": props.checked, "bg-white/20": !props.checked }}> 65 + <Motion.span 66 + class="inline-block h-4 w-4 rounded-full bg-on-primary-fixed shadow-lg" 67 + animate={{ x: props.checked ? 20 : 2 }} 68 + transition={{ duration: 0.15, easing: [0.25, 0.1, 0.25, 1] }} /> 69 + </button> 70 + ); 71 + } 72 + 73 + function StatusLabel(props: { config: EmbeddingsConfig | null }) { 74 + return ( 75 + <span class="font-medium text-sm"> 76 + <Switch fallback={<span class="text-on-surface">Loading...</span>}> 77 + <Match when={props.config?.downloadActive}> 78 + <span class="text-primary">Downloading model files...</span> 79 + </Match> 80 + <Match when={props.config?.downloaded}> 81 + <span class="text-emerald-300">Model ready</span> 82 + </Match> 83 + <Match when={props.config?.lastError}> 84 + <span class="text-red-300">Download failed</span> 85 + </Match> 86 + <Match when={props.config?.enabled}> 87 + <span class="text-primary">Preparing model cache...</span> 88 + </Match> 89 + <Match when={!props.config?.enabled}> 90 + <span class="text-on-surface-variant">Semantic search is off</span> 91 + </Match> 92 + </Switch> 93 + </span> 94 + ); 95 + } 96 + 97 + function StatusLabelWithIcon(props: { config: EmbeddingsConfig | null }) { 98 + return ( 99 + <span class="flex items-center gap-2 text-sm text-on-surface"> 100 + <Switch> 101 + <Match when={props.config?.downloadActive}> 102 + <Icon kind="download" class="text-primary" /> 103 + <span>Downloading model files...</span> 104 + </Match> 105 + <Match when={props.config?.downloaded}> 106 + <Icon kind="complete" class="text-emerald-300" /> 107 + <span>Model ready</span> 108 + </Match> 109 + <Match when={props.config?.lastError}> 110 + <Icon kind="danger" class="text-red-300" /> 111 + <span>Download failed</span> 112 + </Match> 113 + <Match when={props.config?.enabled}> 114 + <Icon kind="download" class="text-primary" /> 115 + <span>Preparing model cache...</span> 116 + </Match> 117 + <Match when={!props.config?.enabled}> 118 + <Icon kind="close" class="text-on-surface-variant" /> 119 + <span>Semantic search is off</span> 120 + </Match> 121 + </Switch> 122 + </span> 123 + ); 124 + } 125 + 126 + type EmbeddingsSettingsProps = { onConfigChange?: (config: EmbeddingsConfig) => void }; 127 + 128 + export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { 129 + const [config, setConfig] = createSignal<EmbeddingsConfig | null>(null); 130 + const [loading, setLoading] = createSignal(true); 131 + const [autoPrepareStarted, setAutoPrepareStarted] = createSignal(false); 132 + 133 + async function loadConfig() { 134 + try { 135 + setLoading(true); 136 + const nextConfig = await getEmbeddingsConfig(); 137 + setConfig(nextConfig); 138 + props.onConfigChange?.(nextConfig); 139 + } catch (error) { 140 + logger.error("failed to load embeddings config", { keyValues: { error: String(error) } }); 141 + } finally { 142 + setLoading(false); 143 + } 144 + } 145 + 146 + async function refreshConfig() { 147 + try { 148 + const nextConfig = await getEmbeddingsConfig(); 149 + setConfig(nextConfig); 150 + props.onConfigChange?.(nextConfig); 151 + } catch (error) { 152 + logger.error("failed to refresh embeddings config", { keyValues: { error: String(error) } }); 153 + } 154 + } 155 + 156 + async function prepareModel() { 157 + try { 158 + const nextConfig = await prepareEmbeddingsModel(); 159 + setConfig(nextConfig); 160 + props.onConfigChange?.(nextConfig); 161 + } catch (error) { 162 + logger.error("failed to prepare embeddings model", { keyValues: { error: String(error) } }); 163 + await refreshConfig(); 164 + } 165 + } 166 + 167 + async function handleToggle() { 168 + const current = config(); 169 + if (!current) { 170 + return; 171 + } 172 + 173 + const nextEnabled = !current.enabled; 174 + try { 175 + await setEmbeddingsEnabled(nextEnabled); 176 + if (!nextEnabled) { 177 + setAutoPrepareStarted(false); 178 + } 179 + 180 + await loadConfig(); 181 + } catch (error) { 182 + logger.error("failed to set embeddings enabled", { keyValues: { error: String(error) } }); 183 + } 184 + } 185 + 186 + const ofProgress = createMemo<[number, number] | null>(() => { 187 + const index = config()?.downloadFileIndex; 188 + const total = config()?.downloadFileTotal; 189 + 190 + if (typeof index === "number" && typeof total === "number" && total > 0) { 191 + return [index, total]; 192 + } 193 + 194 + return null; 195 + }); 196 + 197 + createEffect(() => { 198 + void loadConfig(); 199 + }); 200 + 201 + createEffect(() => { 202 + const current = config(); 203 + if (!current) { 204 + return; 205 + } 206 + 207 + if (!current.enabled) { 208 + setAutoPrepareStarted(false); 209 + return; 210 + } 211 + 212 + if (current.downloaded || current.downloadActive || autoPrepareStarted()) { 213 + return; 214 + } 215 + 216 + setAutoPrepareStarted(true); 217 + void prepareModel(); 218 + }); 219 + 220 + onMount(() => { 221 + const interval = setInterval(() => { 222 + if (config()?.downloadActive) { 223 + void refreshConfig(); 224 + } 225 + }, 1000); 226 + 227 + onCleanup(() => clearInterval(interval)); 228 + }); 229 + 230 + return ( 231 + <section class="panel-surface grid gap-4 p-5"> 232 + <EmbedSettingsHeader config={config()} isLoading={loading()} handleToggle={handleToggle} /> 233 + 234 + <Presence> 235 + <Show when={config()?.enabled && (!config()?.downloaded || config()?.downloadActive || config()?.lastError)}> 236 + <Motion.div 237 + class="grid gap-3 rounded-2xl bg-white/5 p-4" 238 + initial={{ opacity: 0, height: 0 }} 239 + animate={{ opacity: 1, height: "auto" }} 240 + exit={{ opacity: 0, height: 0 }} 241 + transition={{ duration: 0.2 }}> 242 + <div class="flex items-center justify-between gap-3"> 243 + <StatusLabelWithIcon config={config()} /> 244 + <span class="text-xs text-on-surface-variant">{formatProgress(config()?.downloadProgress)}</span> 245 + </div> 246 + 247 + <div class="h-2 overflow-hidden rounded-full bg-white/8"> 248 + <Motion.div 249 + class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim" 250 + animate={{ width: `${Math.max(config()?.downloadProgress ?? 0, 2)}%` }} 251 + transition={{ duration: 0.25 }} /> 252 + </div> 253 + 254 + <div class="grid gap-1 text-xs text-on-surface-variant"> 255 + <Show when={config()?.downloadFile}> 256 + {(filename) => <p class="m-0">Current file: {filename().split("/").at(-1) ?? filename()}</p>} 257 + </Show> 258 + 259 + <Show when={ofProgress()}> 260 + {(value) => { 261 + const [index, total] = value(); 262 + return <p class="m-0">File {index} of {total}</p>; 263 + }} 264 + </Show> 265 + 266 + <Show when={config()?.downloadEtaSeconds}> 267 + {value => <p class="m-0">ETA: {formatEtaSeconds(value())}</p>} 268 + </Show> 269 + 270 + <Show when={config()?.lastError}>{(message) => <p class="m-0 text-red-300">{message()}</p>}</Show> 271 + </div> 272 + 273 + <Show when={!config()?.downloadActive && !config()?.downloaded}> 274 + <DownloadButton config={config()} prepareModel={prepareModel} /> 275 + </Show> 276 + </Motion.div> 277 + </Show> 278 + </Presence> 279 + 280 + <p class="m-0 text-xs leading-relaxed text-on-surface-variant/80"> 281 + Semantic search can find conceptually similar posts even when they do not contain the exact keywords you typed. 282 + </p> 283 + <div class="flex items-center gap-2"> 284 + <StatusLabel config={config()} /> 285 + <ModelDescriptor config={config()} /> 286 + </div> 287 + </section> 288 + ); 289 + }
+16 -12
src/components/search/SearchEmptyState.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 1 2 import { Match, Show, Switch } from "solid-js"; 2 - import { Icon } from "../shared/Icon"; 3 3 4 4 type SearchEmptyStateProps = { reason: "initial" | "no-results" | "no-sync" }; 5 5 ··· 14 14 15 15 function EmptyStateIcon(props: { reason: string }) { 16 16 return ( 17 - <div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-white/5"> 17 + <div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-white/5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 18 18 <Show 19 19 when={props.reason === "no-sync"} 20 20 fallback={<Icon kind="search" class="text-3xl text-on-surface-variant" />}> ··· 45 45 function InitialContent() { 46 46 return ( 47 47 <> 48 - <h3 class="mb-1 text-base font-medium text-on-surface">Search your saved posts</h3> 48 + <h3 class="mb-1 text-base font-medium text-on-surface">Search your saved & liked posts</h3> 49 49 <p class="m-0 text-sm text-on-surface-variant"> 50 - Type a query above to search through your liked and bookmarked posts. 50 + Type a query above to search through the posts you liked or bookmarked. 51 51 </p> 52 52 <KeyboardShortcuts /> 53 53 </> ··· 56 56 57 57 function KeyboardShortcuts() { 58 58 return ( 59 - <div class="mt-4 space-y-1 text-xs text-on-surface-variant/60"> 60 - <p> 61 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd> Focus search from anywhere 62 - </p> 63 - <p> 64 - <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> Cycle search modes 65 - </p> 59 + <div class="my-4 space-y-2 flex items-center justify-center flex-col text-xs text-on-surface-variant/60"> 60 + <div class="flex items-center gap-2"> 61 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd> 62 + Focus search from anywhere 63 + </div> 64 + <div class="flex items-center gap-2"> 65 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> 66 + Cycle search modes 67 + </div> 66 68 </div> 67 69 ); 68 70 } ··· 82 84 return ( 83 85 <> 84 86 <h3 class="mb-1 text-base font-medium text-on-surface">No posts synced yet</h3> 85 - <p class="m-0 text-sm text-on-surface-variant">Sync your liked and bookmarked posts to enable local search.</p> 87 + <p class="m-0 text-sm text-on-surface-variant"> 88 + Sync your liked and bookmarked posts to build the local index for keyword and semantic search. 89 + </p> 86 90 </> 87 91 ); 88 92 }
+48 -20
src/components/search/SearchPanel.test.tsx
··· 6 6 const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); 7 7 const getSyncStatusMock = vi.hoisted(() => vi.fn()); 8 8 const syncPostsMock = vi.hoisted(() => vi.fn()); 9 + const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); 10 + const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); 11 + const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); 9 12 10 13 vi.mock( 11 14 "$/lib/api/search", 12 15 () => ({ 16 + getEmbeddingsConfig: getEmbeddingsConfigMock, 17 + prepareEmbeddingsModel: prepareEmbeddingsModelMock, 18 + setEmbeddingsEnabled: setEmbeddingsEnabledMock, 13 19 searchPosts: searchPostsMock, 14 20 searchPostsNetwork: searchPostsNetworkMock, 15 21 getSyncStatus: getSyncStatusMock, ··· 26 32 searchPostsNetworkMock.mockReset(); 27 33 getSyncStatusMock.mockReset(); 28 34 syncPostsMock.mockReset(); 35 + getEmbeddingsConfigMock.mockReset(); 36 + prepareEmbeddingsModelMock.mockReset(); 37 + setEmbeddingsEnabledMock.mockReset(); 29 38 30 39 getSyncStatusMock.mockResolvedValue([]); 31 40 syncPostsMock.mockResolvedValue({ 32 41 did: "did:plc:test", 33 42 source: "like", 34 - post_count: 100, 35 - last_synced_at: "2026-03-29T12:00:00.000Z", 43 + postCount: 100, 44 + lastSyncedAt: "2026-03-29T12:00:00.000Z", 36 45 }); 46 + getEmbeddingsConfigMock.mockResolvedValue({ 47 + enabled: true, 48 + modelName: "nomic-embed-text-v1.5", 49 + dimensions: 768, 50 + downloaded: true, 51 + downloadActive: false, 52 + }); 53 + prepareEmbeddingsModelMock.mockResolvedValue({ 54 + enabled: true, 55 + modelName: "nomic-embed-text-v1.5", 56 + dimensions: 768, 57 + downloaded: true, 58 + downloadActive: false, 59 + }); 60 + setEmbeddingsEnabledMock.mockResolvedValue(void 0); 37 61 }); 38 62 39 63 it("renders the search panel with initial state", async () => { 40 64 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 41 65 42 - expect(await screen.findByPlaceholderText("Search posts...")).toBeInTheDocument(); 66 + expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument(); 43 67 expect(screen.getByText("Network")).toBeInTheDocument(); 44 68 expect(screen.getByText("Keyword")).toBeInTheDocument(); 45 69 expect(screen.getByText("Semantic")).toBeInTheDocument(); ··· 70 94 71 95 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 72 96 73 - const input = await screen.findByPlaceholderText("Search posts..."); 97 + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 74 98 fireEvent.input(input, { target: { value: "test query" } }); 75 99 76 100 vi.advanceTimersByTime(350); ··· 79 103 expect(searchPostsNetworkMock).toHaveBeenCalledWith("test query", "top", 25); 80 104 }); 81 105 82 - expect(await screen.findByText("Test post content")).toBeInTheDocument(); 106 + expect(await screen.findByText(/post content/i)).toBeInTheDocument(); 83 107 }); 84 108 85 109 it("performs local search in keyword mode", async () => { 110 + getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); 86 111 searchPostsMock.mockResolvedValue([{ 87 112 uri: "at://test", 88 113 cid: "cid-1", 89 - author_did: "did:plc:test", 90 - author_handle: "test.bsky.social", 114 + authorDid: "did:plc:test", 115 + authorHandle: "test.bsky.social", 91 116 text: "Local test post", 92 - created_at: "2026-03-29T12:00:00.000Z", 93 - indexed_at: "2026-03-29T12:00:00.000Z", 117 + createdAt: "2026-03-29T12:00:00.000Z", 94 118 source: "like" as const, 119 + score: 1, 120 + keywordMatch: true, 121 + semanticMatch: false, 95 122 }]); 96 123 97 124 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 98 125 99 126 const keywordButton = screen.getByRole("button", { name: /keyword/i }); 100 127 fireEvent.click(keywordButton); 128 + expect(keywordButton).toHaveAttribute("aria-pressed", "true"); 101 129 102 - const input = await screen.findByPlaceholderText("Search posts..."); 130 + const input = screen.getByPlaceholderText("Search your saved & liked posts..."); 103 131 fireEvent.input(input, { target: { value: "test query" } }); 104 132 105 - vi.advanceTimersByTime(350); 106 - 107 - await waitFor(() => { 108 - expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); 109 - }); 133 + await vi.advanceTimersByTimeAsync(350); 134 + await Promise.resolve(); 135 + await Promise.resolve(); 110 136 111 - expect(await screen.findByText("Local test post")).toBeInTheDocument(); 137 + expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); 138 + expect(screen.getByText("Liked")).toBeInTheDocument(); 112 139 }); 113 140 114 141 it("cycles through modes with Tab key", async () => { 115 142 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 116 143 117 - const input = await screen.findByPlaceholderText("Search posts..."); 144 + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 118 145 input.focus(); 119 146 fireEvent.keyDown(input, { key: "Tab" }); 120 147 ··· 134 161 135 162 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 136 163 137 - const input = await screen.findByPlaceholderText("Search posts..."); 164 + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 138 165 fireEvent.input(input, { target: { value: "test" } }); 139 166 vi.advanceTimersByTime(350); 140 167 ··· 152 179 153 180 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); 154 181 155 - const input = await screen.findByPlaceholderText("Search posts..."); 182 + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 156 183 fireEvent.input(input, { target: { value: "test" } }); 157 184 vi.advanceTimersByTime(350); 158 185 ··· 162 189 }); 163 190 164 191 it("shows empty state when no results found", async () => { 192 + getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); 165 193 searchPostsMock.mockResolvedValue([]); 166 194 167 195 render(() => <SearchPanel session={{ did: "did:plc:test", handle: "test.bsky.social" }} />); ··· 169 197 const keywordButton = screen.getByRole("button", { name: /keyword/i }); 170 198 fireEvent.click(keywordButton); 171 199 172 - const input = await screen.findByPlaceholderText("Search posts..."); 200 + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); 173 201 fireEvent.input(input, { target: { value: "nonexistent" } }); 174 202 vi.advanceTimersByTime(350); 175 203
+248 -87
src/components/search/SearchPanel.tsx
··· 1 1 import { Icon, SearchModeIcon } from "$/components/shared/Icon"; 2 2 import { 3 + type EmbeddingsConfig, 4 + getEmbeddingsConfig, 3 5 type LocalPostResult, 4 6 type NetworkSearchResult, 5 7 type SearchMode, 6 8 searchPosts, 7 9 searchPostsNetwork, 10 + type SyncStatus, 8 11 } from "$/lib/api/search"; 12 + import { formatRelativeTime } from "$/lib/feeds"; 9 13 import type { ActiveSession } from "$/lib/types"; 10 14 import { normalizeError } from "$/lib/utils/text"; 11 15 import * as logger from "@tauri-apps/plugin-log"; 12 16 import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 13 17 import { Motion, Presence } from "solid-motionone"; 18 + import { PostCount } from "../shared/PostCount"; 19 + import { EmbeddingsSettings } from "./EmbeddingsSettings"; 14 20 import { SearchEmptyState } from "./SearchEmptyState"; 15 21 import { SearchResultCard } from "./SearchResultCard"; 16 22 import { SyncStatusPanel } from "./SyncStatusPanel"; ··· 19 25 20 26 function ModeLabel(props: { mode: SearchMode }) { 21 27 return ( 22 - <span class="flex items-center gap-1"> 28 + <span class="flex items-center gap-1.5"> 23 29 <SearchModeIcon mode={props.mode} class="text-base" /> 24 30 <Switch> 25 31 <Match when={props.mode === "network"}>Network</Match> ··· 42 48 const [error, setError] = createSignal<string | null>(null); 43 49 const [resultCount, setResultCount] = createSignal(0); 44 50 const [hasSearched, setHasSearched] = createSignal(false); 51 + const [syncStatus, setSyncStatus] = createSignal<SyncStatus[]>([]); 52 + const [embeddingsConfig, setEmbeddingsConfig] = createSignal<EmbeddingsConfig | null>(null); 45 53 46 54 let searchInputRef: HTMLInputElement | undefined; 47 55 let debounceTimer: ReturnType<typeof setTimeout> | undefined; 48 56 49 57 const isLocalMode = createMemo(() => mode() !== "network"); 58 + const semanticEnabled = createMemo(() => embeddingsConfig()?.enabled ?? true); 59 + const totalIndexedPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); 60 + const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); 61 + const lastSync = createMemo(() => { 62 + const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 63 + if (timestamps.length === 0) { 64 + return null; 65 + } 66 + 67 + return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 68 + }); 69 + const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); 70 + 71 + async function loadEmbeddingsConfig() { 72 + try { 73 + setEmbeddingsConfig(await getEmbeddingsConfig()); 74 + } catch (err) { 75 + logger.error("failed to load embeddings config", { keyValues: { error: normalizeError(err) } }); 76 + } 77 + } 50 78 51 79 async function performSearch(searchQuery: string, searchMode: SearchMode) { 52 80 if (!searchQuery.trim()) { 81 + clearResults(); 82 + return; 83 + } 84 + 85 + if (searchMode === "semantic" && !semanticEnabled()) { 86 + setError("Semantic search is disabled. Re-enable embeddings to use this mode."); 87 + setHasSearched(true); 53 88 setResults([]); 54 89 setNetworkResults(null); 55 90 setResultCount(0); ··· 63 98 if (searchMode === "network") { 64 99 const response = await searchPostsNetwork(searchQuery, "top", 25); 65 100 setNetworkResults(response); 101 + setResults([]); 66 102 setResultCount(response.posts.length); 67 103 } else { 68 104 const response = await searchPosts(searchQuery, searchMode, 50); 69 105 setResults(response); 106 + setNetworkResults(null); 70 107 setResultCount(response.length); 71 108 } 72 109 setHasSearched(true); 73 110 } catch (err) { 74 111 const errorMsg = normalizeError(err); 75 112 setError(errorMsg); 113 + setResults([]); 114 + setNetworkResults(null); 115 + setResultCount(0); 116 + setHasSearched(true); 76 117 logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMsg } }); 77 118 } finally { 78 119 setLoading(false); 79 120 } 80 121 } 81 122 123 + function clearResults() { 124 + setResults([]); 125 + setNetworkResults(null); 126 + setResultCount(0); 127 + setError(null); 128 + setHasSearched(false); 129 + } 130 + 82 131 function handleInput(value: string) { 83 132 setQuery(value); 84 133 clearTimeout(debounceTimer); ··· 88 137 } 89 138 90 139 function handleModeChange(newMode: SearchMode) { 140 + if (newMode === "semantic" && !semanticEnabled()) { 141 + return; 142 + } 143 + 91 144 setMode(newMode); 92 145 if (query().trim()) { 93 146 void performSearch(query(), newMode); 147 + } else { 148 + setError(null); 94 149 } 95 150 } 96 151 97 152 function cycleMode() { 98 - const currentIndex = MODES.indexOf(mode()); 99 - const nextIndex = (currentIndex + 1) % MODES.length; 100 - handleModeChange(MODES[nextIndex]); 153 + const availableModes = cycleModes(); 154 + const currentIndex = availableModes.indexOf(mode()); 155 + const nextIndex = (currentIndex + 1) % availableModes.length; 156 + handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); 101 157 } 102 158 103 159 function clearSearch() { 104 160 setQuery(""); 105 - setResults([]); 106 - setNetworkResults(null); 107 - setResultCount(0); 108 - setHasSearched(false); 109 - setError(null); 161 + clearResults(); 110 162 searchInputRef?.focus(); 111 163 } 112 164 ··· 120 172 } 121 173 122 174 function handleGlobalKeyDown(event: KeyboardEvent) { 123 - if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key === "f")) { 175 + if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f")) { 124 176 const target = event.target as HTMLElement; 125 177 if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { 126 178 event.preventDefault(); ··· 131 183 132 184 onMount(() => { 133 185 document.addEventListener("keydown", handleGlobalKeyDown); 186 + void loadEmbeddingsConfig(); 187 + 134 188 onCleanup(() => { 135 189 document.removeEventListener("keydown", handleGlobalKeyDown); 136 190 clearTimeout(debounceTimer); 137 191 }); 138 192 }); 139 193 194 + createEffect(() => { 195 + if (mode() === "semantic" && !semanticEnabled()) { 196 + setMode("keyword"); 197 + } 198 + }); 199 + 140 200 return ( 141 - <article class="grid min-h-0 grid-rows-[auto_auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 142 - <SearchHeader 143 - error={error()} 144 - hasSearched={hasSearched()} 145 - loading={loading()} 146 - mode={mode()} 147 - query={query()} 148 - resultCount={resultCount()} 149 - onModeChange={handleModeChange} 150 - onQueryChange={handleInput} 151 - inputRef={(el) => { 152 - searchInputRef = el; 153 - }} 154 - onKeyDown={handleKeyDown} 155 - onClear={clearSearch} /> 201 + <div class="grid min-h-0 gap-6 xl:grid-cols-[minmax(0,1fr)_20rem]"> 202 + <section class="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden rounded-4xl bg-surface-container shadow-[inset_0_0_0_1px_rgba(255,255,255,0.035)]"> 203 + <SearchHeader 204 + error={error()} 205 + hasSearched={hasSearched()} 206 + loading={loading()} 207 + mode={mode()} 208 + query={query()} 209 + resultCount={resultCount()} 210 + semanticEnabled={semanticEnabled()} 211 + totalIndexedPosts={totalIndexedPosts()} 212 + lastSync={lastSync()} 213 + onModeChange={handleModeChange} 214 + onQueryChange={handleInput} 215 + inputRef={(element) => { 216 + searchInputRef = element; 217 + }} 218 + onKeyDown={handleKeyDown} 219 + onClear={clearSearch} /> 156 220 157 - <SyncStatusPanel did={props.session.did} /> 221 + <SearchViewport 222 + error={error()} 223 + hasLocalPosts={hasLocalPosts()} 224 + hasSearched={hasSearched()} 225 + isLocalMode={isLocalMode()} 226 + loading={loading()} 227 + localResults={results()} 228 + mode={mode()} 229 + networkResults={networkResults()} 230 + query={query()} /> 231 + </section> 158 232 159 - <SearchViewport 160 - hasSearched={hasSearched()} 161 - isLocalMode={isLocalMode()} 162 - loading={loading()} 163 - localResults={results()} 164 - networkResults={networkResults()} 165 - query={query()} /> 166 - </article> 233 + <aside class="grid content-start gap-4 overflow-y-auto"> 234 + <SyncStatusPanel did={props.session.did} onStatusChange={setSyncStatus} /> 235 + <EmbeddingsSettings 236 + onConfigChange={(nextConfig) => { 237 + setEmbeddingsConfig(nextConfig); 238 + }} /> 239 + <SearchTipsCard /> 240 + </aside> 241 + </div> 167 242 ); 168 243 } 169 244 ··· 172 247 error: string | null; 173 248 hasSearched: boolean; 174 249 inputRef: (el: HTMLInputElement) => void; 250 + lastSync: string | null; 175 251 loading: boolean; 176 252 mode: SearchMode; 177 253 onClear: () => void; ··· 180 256 onQueryChange: (value: string) => void; 181 257 query: string; 182 258 resultCount: number; 259 + semanticEnabled: boolean; 260 + totalIndexedPosts: number; 183 261 }, 184 262 ) { 185 263 return ( 186 - <header class="grid gap-4 px-6 pb-4 pt-6"> 264 + <header class="grid gap-4 px-6 pb-5 pt-6"> 187 265 <SearchInput 188 266 error={props.error} 189 267 inputRef={props.inputRef} ··· 193 271 onKeyDown={props.onKeyDown} 194 272 onQueryChange={props.onQueryChange} /> 195 273 196 - <div class="flex items-center justify-between"> 197 - <ModeSelector activeMode={props.mode} onModeChange={props.onModeChange} /> 274 + <div class="flex items-center justify-between gap-4"> 275 + <ModeSelector 276 + activeMode={props.mode} 277 + semanticEnabled={props.semanticEnabled} 278 + onModeChange={props.onModeChange} /> 198 279 <span class="text-xs text-on-surface-variant"> 199 280 <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> to switch modes 200 281 </span> 201 282 </div> 202 283 203 - <Show when={props.hasSearched && !props.error}> 204 - <ResultCount count={props.resultCount} /> 205 - </Show> 284 + <ResultMeta 285 + hasSearched={props.hasSearched} 286 + resultCount={props.resultCount} 287 + totalIndexedPosts={props.totalIndexedPosts} 288 + lastSync={props.lastSync} /> 206 289 </header> 207 290 ); 208 291 } 209 292 210 - function ResultCount(props: { count: number }) { 293 + function ResultMeta( 294 + props: { hasSearched: boolean; lastSync: string | null; resultCount: number; totalIndexedPosts: number }, 295 + ) { 211 296 return ( 212 - <div class="flex items-center justify-between border-t border-white/5 pt-3"> 297 + <div class="flex items-center justify-between gap-4 border-t border-white/5 pt-3"> 213 298 <span class="text-sm text-on-surface-variant"> 214 - Found <span class="font-medium text-on-surface">{props.count}</span> results 299 + <Show 300 + when={props.hasSearched} 301 + fallback="Search your liked and bookmarked posts locally, or search the network."> 302 + <span> 303 + Found <span class="font-medium text-on-surface">{props.resultCount}</span> results 304 + </span> 305 + </Show> 306 + </span> 307 + 308 + <span class="text-xs text-on-surface-variant"> 309 + <Show when={props.totalIndexedPosts > 0}> 310 + <PostCount totalPosts={props.totalIndexedPosts} lastSync={props.lastSync} inline /> 311 + </Show> 215 312 </span> 216 313 </div> 217 314 ); ··· 229 326 }, 230 327 ) { 231 328 return ( 232 - <div class="relative"> 233 - <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 234 - <Icon kind="search" class="text-lg" /> 235 - </div> 329 + <div class="grid gap-2"> 330 + <div class="relative"> 331 + <div class="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant"> 332 + <Icon kind="search" class="text-lg" /> 333 + </div> 236 334 237 - <input 238 - ref={props.inputRef} 239 - type="text" 240 - value={props.query} 241 - placeholder="Search posts..." 242 - class="w-full rounded-2xl border-0 bg-black/40 py-3 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 243 - onInput={(e) => props.onQueryChange(e.currentTarget.value)} 244 - onKeyDown={(e) => props.onKeyDown(e)} /> 335 + <input 336 + ref={props.inputRef} 337 + type="text" 338 + value={props.query} 339 + placeholder="Search your saved & liked posts..." 340 + class="w-full rounded-3xl border-0 bg-black/40 py-3.5 pl-12 pr-20 text-base text-on-surface placeholder:text-on-surface-variant/50 outline-none ring-1 ring-white/5 transition-all focus:ring-primary/50" 341 + onInput={(event) => props.onQueryChange(event.currentTarget.value)} 342 + onKeyDown={(event) => props.onKeyDown(event)} /> 245 343 246 - <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 247 - <LoadingIndicator loading={props.loading} /> 248 - <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 344 + <div class="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2"> 345 + <LoadingIndicator loading={props.loading} /> 346 + <ClearButton query={props.query} loading={props.loading} onClear={props.onClear} /> 347 + </div> 249 348 </div> 349 + 350 + <Show when={props.error}> 351 + {(message) => ( 352 + <div class="rounded-2xl bg-red-500/10 px-3 py-2 text-sm text-red-200 shadow-[inset_0_0_0_1px_rgba(239,68,68,0.15)]"> 353 + {message()} 354 + </div> 355 + )} 356 + </Show> 250 357 </div> 251 358 ); 252 359 } ··· 275 382 ); 276 383 } 277 384 278 - function ModeSelector(props: { activeMode: SearchMode; onModeChange: (mode: SearchMode) => void }) { 385 + function ModeSelector( 386 + props: { activeMode: SearchMode; semanticEnabled: boolean; onModeChange: (mode: SearchMode) => void }, 387 + ) { 279 388 const [indicatorStyle, setIndicatorStyle] = createSignal({ left: "0px", width: "0px" }); 280 389 const [containerRef, setContainerRef] = createSignal<HTMLDivElement | undefined>(); 281 390 282 391 createEffect(() => { 283 - const mode = props.activeMode; 284 392 const ref = containerRef(); 285 - if (!ref) return; 393 + if (!ref) { 394 + return; 395 + } 286 396 287 397 const buttons = ref.querySelectorAll("button"); 288 - const activeIndex = MODES.indexOf(mode); 398 + const activeIndex = MODES.indexOf(props.activeMode); 289 399 const activeButton = buttons[activeIndex]; 290 - if (!activeButton) return; 400 + if (!activeButton) { 401 + return; 402 + } 291 403 292 404 const rect = activeButton.getBoundingClientRect(); 293 405 const containerRect = ref.getBoundingClientRect(); ··· 296 408 }); 297 409 298 410 return ( 299 - <div ref={setContainerRef} class="relative flex gap-1 rounded-full bg-black/30 p-1"> 411 + <div ref={setContainerRef} class="relative flex flex-wrap gap-1 rounded-full bg-black/30 p-1"> 300 412 <Motion.div 301 413 class="absolute inset-y-1 rounded-full bg-surface-container-high shadow-[inset_0_0_0_1px_rgba(125,175,255,0.18)]" 302 414 animate={indicatorStyle()} 303 415 transition={{ duration: 0.2, easing: [0.25, 0.1, 0.25, 1] }} /> 304 416 305 417 <For each={MODES}> 306 - {(searchMode) => ( 307 - <button 308 - type="button" 309 - aria-pressed={props.activeMode === searchMode} 310 - class="relative z-10 inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-150" 311 - classList={{ 312 - "text-primary": props.activeMode === searchMode, 313 - "text-on-surface-variant hover:text-on-surface": props.activeMode !== searchMode, 314 - }} 315 - onClick={() => props.onModeChange(searchMode)}> 316 - <ModeLabel mode={searchMode} /> 317 - </button> 318 - )} 418 + {(searchMode) => { 419 + const disabled = searchMode === "semantic" && !props.semanticEnabled; 420 + return ( 421 + <button 422 + type="button" 423 + aria-pressed={props.activeMode === searchMode} 424 + disabled={disabled} 425 + title={disabled ? "Enable embeddings to use semantic search." : undefined} 426 + class="relative z-10 inline-flex items-center gap-2 rounded-full px-4 py-2 text-sm font-medium transition-colors duration-150 disabled:cursor-not-allowed" 427 + classList={{ 428 + "text-primary": props.activeMode === searchMode, 429 + "text-on-surface-variant hover:text-on-surface": props.activeMode !== searchMode && !disabled, 430 + "text-on-surface-variant/40": disabled, 431 + }} 432 + onClick={() => props.onModeChange(searchMode)}> 433 + <ModeLabel mode={searchMode} /> 434 + </button> 435 + ); 436 + }} 319 437 </For> 320 438 </div> 321 439 ); ··· 323 441 324 442 function SearchViewport( 325 443 props: { 444 + error: string | null; 445 + hasLocalPosts: boolean; 326 446 hasSearched: boolean; 327 447 isLocalMode: boolean; 328 448 loading: boolean; 329 449 localResults: LocalPostResult[]; 450 + mode: SearchMode; 330 451 networkResults: NetworkSearchResult | null; 331 452 query: string; 332 453 }, ··· 344 465 345 466 function SearchState( 346 467 props: { 468 + error: string | null; 469 + hasLocalPosts: boolean; 347 470 hasSearched: boolean; 348 471 isLocalMode: boolean; 349 472 loading: boolean; 350 473 localResults: LocalPostResult[]; 474 + mode: SearchMode; 351 475 networkResults: NetworkSearchResult | null; 352 476 query: string; 353 477 }, ··· 355 479 return ( 356 480 <Presence> 357 481 <Switch> 482 + <Match when={props.error && props.query}> 483 + <EmptyStateView reason={props.isLocalMode && !props.hasLocalPosts ? "no-sync" : "no-results"} /> 484 + </Match> 485 + 486 + <Match when={props.isLocalMode && !props.hasLocalPosts}> 487 + <EmptyStateView reason="no-sync" /> 488 + </Match> 489 + 358 490 <Match when={!props.hasSearched && !props.query}> 359 491 <EmptyStateView reason="initial" /> 360 492 </Match> ··· 368 500 </Match> 369 501 370 502 <Match when={props.isLocalMode}> 371 - <LocalResultsList results={props.localResults} /> 503 + <LocalResultsList query={props.query} results={props.localResults} /> 372 504 </Match> 373 505 374 506 <Match when={!props.isLocalMode && props.networkResults}> 375 - <NetworkResultsList results={props.networkResults} /> 507 + <NetworkResultsList query={props.query} results={props.networkResults} /> 376 508 </Match> 377 509 </Switch> 378 510 </Presence> 379 511 ); 380 512 } 381 513 382 - function EmptyStateView(props: { reason: "initial" | "no-results" }) { 514 + function EmptyStateView(props: { reason: "initial" | "no-results" | "no-sync" }) { 383 515 return ( 384 516 <Motion.div 385 517 class="grid place-items-center px-6 py-16" ··· 392 524 ); 393 525 } 394 526 395 - function LocalResultsList(props: { results: LocalPostResult[] }) { 527 + function LocalResultsList(props: { query: string; results: LocalPostResult[] }) { 396 528 return ( 397 529 <Motion.div 398 530 class="grid gap-2" ··· 409 541 transition={{ duration: 0.2, delay: Math.min(index() * 0.03, 0.18) }} 410 542 role="listitem"> 411 543 <SearchResultCard 412 - authorHandle={result.author_handle} 544 + authorHandle={result.authorHandle ?? "unknown"} 413 545 source={result.source} 414 - text={result.text} 415 - createdAt={result.created_at} 416 - isSemanticMatch={false} /> 546 + text={result.text ?? ""} 547 + createdAt={result.createdAt ?? ""} 548 + isSemanticMatch={result.semanticMatch && !result.keywordMatch} 549 + query={props.query} /> 417 550 </Motion.div> 418 551 )} 419 552 </For> ··· 422 555 ); 423 556 } 424 557 425 - function NetworkResultsList(props: { results: NetworkSearchResult | null }) { 558 + function NetworkResultsList(props: { query: string; results: NetworkSearchResult | null }) { 426 559 return ( 427 560 <Motion.div 428 561 class="grid gap-2" ··· 445 578 createdAt={post.indexedAt} 446 579 likeCount={post.likeCount ?? 0} 447 580 replyCount={post.replyCount ?? 0} 448 - isSemanticMatch={false} /> 581 + query={props.query} /> 449 582 </Motion.div> 450 583 )} 451 584 </For> ··· 466 599 </div> 467 600 ); 468 601 } 602 + 603 + function SearchTipsCard() { 604 + return ( 605 + <section class="panel-surface grid gap-3 p-5"> 606 + <p class="m-0 text-sm font-medium text-on-surface">Search Tips</p> 607 + <div class="grid gap-2 grid-cols-2 text-xs text-on-surface-variant"> 608 + <p class="m-0 flex items-center gap-2"> 609 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">/</kbd> 610 + <span>Focus search from anywhere</span> 611 + </p> 612 + <p class="m-0 flex items-center gap-2"> 613 + <kbd class="rounded bg-white/10 px-1.5 py-0.5">Tab</kbd> 614 + <span>Cycle search modes</span> 615 + </p> 616 + <div class="flex flex-col items-start gap-1 col-span-2"> 617 + <div class="m-0 flex gap-2 items-start"> 618 + <div>·</div> 619 + <div>Use keyword mode for exact terms and hybrid mode for broader recall.</div> 620 + </div> 621 + <div class="m-0 flex gap-2 items-start"> 622 + <div>·</div> 623 + <div>Network search queries public Bluesky posts without using your local index.</div> 624 + </div> 625 + </div> 626 + </div> 627 + </section> 628 + ); 629 + }
+67 -61
src/components/search/SearchResultCard.tsx
··· 3 3 import { createMemo, type JSX, Show } from "solid-js"; 4 4 import { Icon } from "../shared/Icon"; 5 5 6 - type SearchResultCardProps = { 7 - authorHandle: string; 8 - source: "like" | "bookmark" | "network"; 9 - text: string; 10 - createdAt: string; 11 - likeCount?: number; 12 - replyCount?: number; 13 - isSemanticMatch?: boolean; 14 - query?: string; 15 - }; 16 - 17 - export function SearchResultCard(props: SearchResultCardProps) { 18 - const avatarLabel = createMemo(() => props.authorHandle.slice(0, 1).toUpperCase() || "?"); 19 - 20 - const formattedTime = createMemo(() => formatRelativeTime(props.createdAt)); 21 - 22 - const sourceLabel = createMemo(() => { 23 - switch (props.source) { 24 - case "like": { 25 - return "Liked"; 26 - } 27 - case "bookmark": { 28 - return "Bookmarked"; 29 - } 30 - default: { 31 - return null; 32 - } 33 - } 34 - }); 35 - 36 - const highlightedText = createMemo(() => { 37 - if (!props.query || !props.text) { 38 - return props.text; 39 - } 40 - 41 - const parts = props.text.split(new RegExp(`(${escapeForRegex(props.query)})`, "gi")); 42 - return parts.map((part) => { 43 - if (part.toLowerCase() === props.query?.toLowerCase()) { 44 - return <mark class="rounded bg-primary/20 px-0.5 text-primary">{part}</mark>; 45 - } 46 - return part; 47 - }); 48 - }); 49 - 50 - return ( 51 - <article 52 - class="group cursor-pointer rounded-2xl bg-surface px-5 py-4 transition-colors duration-150 hover:bg-white/3" 53 - role="article"> 54 - <CardContent 55 - avatarLabel={avatarLabel()} 56 - authorHandle={props.authorHandle} 57 - time={formattedTime()} 58 - isSemantic={props.isSemanticMatch} 59 - text={highlightedText()} 60 - likes={props.likeCount} 61 - replies={props.replyCount} 62 - sourceLabel={sourceLabel()} /> 63 - </article> 64 - ); 65 - } 66 - 67 6 function CardContent( 68 7 props: { 69 8 avatarLabel: string; ··· 156 95 </span> 157 96 ); 158 97 } 98 + 99 + type SearchResultCardProps = { 100 + authorHandle: string; 101 + source: "like" | "bookmark" | "network"; 102 + text: string; 103 + createdAt: string; 104 + likeCount?: number; 105 + replyCount?: number; 106 + isSemanticMatch?: boolean; 107 + query?: string; 108 + }; 109 + 110 + export function SearchResultCard(props: SearchResultCardProps) { 111 + const avatarLabel = createMemo(() => props.authorHandle.slice(0, 1).toUpperCase() || "?"); 112 + const formattedTime = createMemo(() => (props.createdAt ? formatRelativeTime(props.createdAt) : "Unknown date")); 113 + 114 + const sourceLabel = createMemo(() => { 115 + switch (props.source) { 116 + case "like": { 117 + return "Liked"; 118 + } 119 + case "bookmark": { 120 + return "Bookmarked"; 121 + } 122 + default: { 123 + return null; 124 + } 125 + } 126 + }); 127 + 128 + const highlightedText = createMemo(() => { 129 + if (!props.query || !props.text) { 130 + return props.text; 131 + } 132 + 133 + const tokens = [...new Set(props.query.split(/\s+/).map((token) => token.trim()).filter(Boolean))]; 134 + if (tokens.length === 0) { 135 + return props.text; 136 + } 137 + 138 + const pattern = tokens.toSorted((left, right) => right.length - left.length).map((token) => escapeForRegex(token)) 139 + .join("|"); 140 + const parts = props.text.split(new RegExp(`(${pattern})`, "gi")); 141 + return parts.map((part) => { 142 + if (tokens.some((token) => token.toLowerCase() === part.toLowerCase())) { 143 + return <mark class="rounded bg-primary/20 px-0.5 text-primary">{part}</mark>; 144 + } 145 + return part; 146 + }); 147 + }); 148 + 149 + return ( 150 + <article 151 + class="group cursor-pointer rounded-2xl bg-surface px-5 py-4 transition-colors duration-150 hover:bg-white/3" 152 + role="article"> 153 + <CardContent 154 + avatarLabel={avatarLabel()} 155 + authorHandle={props.authorHandle} 156 + time={formattedTime()} 157 + isSemantic={props.isSemanticMatch} 158 + text={highlightedText()} 159 + likes={props.likeCount} 160 + replies={props.replyCount} 161 + sourceLabel={sourceLabel()} /> 162 + </article> 163 + ); 164 + }
+214
src/components/search/SyncStatusPanel.test.tsx
··· 1 + import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 3 + import { SyncStatusPanel } from "./SyncStatusPanel"; 4 + 5 + const getSyncStatusMock = vi.hoisted(() => vi.fn()); 6 + const syncPostsMock = vi.hoisted(() => vi.fn()); 7 + const reindexEmbeddingsMock = vi.hoisted(() => vi.fn()); 8 + 9 + vi.mock( 10 + "$/lib/api/search", 11 + () => ({ getSyncStatus: getSyncStatusMock, syncPosts: syncPostsMock, reindexEmbeddings: reindexEmbeddingsMock }), 12 + ); 13 + 14 + vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); 15 + 16 + describe("SyncStatusPanel", () => { 17 + beforeEach(() => { 18 + vi.useFakeTimers(); 19 + getSyncStatusMock.mockReset(); 20 + syncPostsMock.mockReset(); 21 + reindexEmbeddingsMock.mockReset(); 22 + 23 + getSyncStatusMock.mockResolvedValue([{ 24 + did: "did:plc:test", 25 + source: "like", 26 + postCount: 100, 27 + lastSyncedAt: "2026-03-29T12:00:00.000Z", 28 + cursor: "cursor-123", 29 + }, { 30 + did: "did:plc:test", 31 + source: "bookmark", 32 + postCount: 50, 33 + lastSyncedAt: "2026-03-29T11:00:00.000Z", 34 + cursor: null, 35 + }]); 36 + 37 + syncPostsMock.mockResolvedValue({ 38 + did: "did:plc:test", 39 + source: "like", 40 + postCount: 150, 41 + lastSyncedAt: "2026-03-29T13:00:00.000Z", 42 + }); 43 + 44 + reindexEmbeddingsMock.mockResolvedValue(150); 45 + }); 46 + 47 + afterEach(() => { 48 + vi.useRealTimers(); 49 + }); 50 + 51 + it("renders sync status with post counts", async () => { 52 + render(() => <SyncStatusPanel did="did:plc:test" />); 53 + 54 + expect(await screen.findByText("Ready")).toBeInTheDocument(); 55 + expect(await screen.findByText(/150/)).toBeInTheDocument(); 56 + expect(await screen.findByText(/posts indexed/i)).toBeInTheDocument(); 57 + }); 58 + 59 + it("shows last sync time", async () => { 60 + render(() => <SyncStatusPanel did="did:plc:test" />); 61 + 62 + expect(await screen.findByText(/last sync/i)).toBeInTheDocument(); 63 + }); 64 + 65 + it("triggers sync when clicking sync button", async () => { 66 + render(() => <SyncStatusPanel did="did:plc:test" />); 67 + 68 + const syncButton = await screen.findByRole("button", { name: /sync now/i }); 69 + fireEvent.click(syncButton); 70 + 71 + await waitFor(() => { 72 + expect(syncPostsMock).toHaveBeenCalledWith("did:plc:test", "like"); 73 + expect(syncPostsMock).toHaveBeenCalledWith("did:plc:test", "bookmark"); 74 + }); 75 + }); 76 + 77 + it("shows syncing state during sync", async () => { 78 + syncPostsMock.mockImplementation(() => 79 + new Promise((resolve) => 80 + setTimeout( 81 + () => 82 + resolve({ did: "did:plc:test", source: "like", postCount: 150, lastSyncedAt: "2026-03-29T13:00:00.000Z" }), 83 + 100, 84 + ) 85 + ) 86 + ); 87 + 88 + render(() => <SyncStatusPanel did="did:plc:test" />); 89 + 90 + const syncButton = await screen.findByRole("button", { name: /sync now/i }); 91 + fireEvent.click(syncButton); 92 + 93 + // Check that the button shows syncing state and is disabled 94 + await waitFor(() => { 95 + expect(screen.getByRole("button", { name: /syncing/i })).toBeDisabled(); 96 + }); 97 + }, 5000); 98 + 99 + it("shows reindex button when posts exist", async () => { 100 + render(() => <SyncStatusPanel did="did:plc:test" />); 101 + 102 + const reindexButton = await screen.findByRole("button", { name: /reindex/i }); 103 + expect(reindexButton).toBeInTheDocument(); 104 + }); 105 + 106 + it("triggers reindex when clicking reindex button", async () => { 107 + render(() => <SyncStatusPanel did="did:plc:test" />); 108 + 109 + const reindexButton = await screen.findByRole("button", { name: /reindex/i }); 110 + fireEvent.click(reindexButton); 111 + 112 + await waitFor(() => { 113 + expect(reindexEmbeddingsMock).toHaveBeenCalled(); 114 + }); 115 + }); 116 + 117 + it("shows reindexing state during reindex", async () => { 118 + reindexEmbeddingsMock.mockImplementation(() => new Promise(() => {})); // Never resolves 119 + 120 + render(() => <SyncStatusPanel did="did:plc:test" />); 121 + 122 + const reindexButton = await screen.findByRole("button", { name: /reindex/i }); 123 + fireEvent.click(reindexButton); 124 + 125 + await waitFor(() => { 126 + expect(screen.getByRole("button", { name: /reindexing/i })).toBeInTheDocument(); 127 + }); 128 + }); 129 + 130 + it("shows progress bars during operations", async () => { 131 + syncPostsMock.mockImplementation(() => new Promise(() => {})); // Never resolves 132 + 133 + const { container } = render(() => <SyncStatusPanel did="did:plc:test" />); 134 + 135 + const syncButton = await screen.findByRole("button", { name: /sync now/i }); 136 + fireEvent.click(syncButton); 137 + 138 + // Progress bar should be visible (it's a div with gradient) 139 + await waitFor(() => { 140 + const progressBars = container.querySelectorAll("[class*=\"bg-linear-to-r\"]"); 141 + expect(progressBars.length).toBeGreaterThan(0); 142 + }); 143 + }); 144 + 145 + it("shows source-specific progress bars", async () => { 146 + render(() => <SyncStatusPanel did="did:plc:test" />); 147 + 148 + expect(await screen.findByText(/liked posts/i)).toBeInTheDocument(); 149 + expect(await screen.findByText(/bookmarked posts/i)).toBeInTheDocument(); 150 + }); 151 + 152 + it("hides reindex button when no posts exist", async () => { 153 + getSyncStatusMock.mockResolvedValue([{ 154 + did: "did:plc:test", 155 + source: "like", 156 + postCount: 0, 157 + lastSyncedAt: null, 158 + cursor: null, 159 + }]); 160 + 161 + render(() => <SyncStatusPanel did="did:plc:test" />); 162 + 163 + await waitFor(() => { 164 + const reindexButton = screen.queryByRole("button", { name: /reindex/i }); 165 + expect(reindexButton).not.toBeInTheDocument(); 166 + }); 167 + }); 168 + 169 + it("polls for sync status updates", async () => { 170 + render(() => <SyncStatusPanel did="did:plc:test" />); 171 + 172 + await waitFor(() => { 173 + expect(getSyncStatusMock).toHaveBeenCalledTimes(1); 174 + }); 175 + 176 + vi.advanceTimersByTime(60_000); 177 + 178 + await waitFor(() => { 179 + expect(getSyncStatusMock).toHaveBeenCalledTimes(2); 180 + }); 181 + }); 182 + 183 + it("handles sync errors gracefully", async () => { 184 + syncPostsMock.mockRejectedValue(new Error("Sync failed")); 185 + 186 + render(() => <SyncStatusPanel did="did:plc:test" />); 187 + 188 + const syncButton = await screen.findByRole("button", { name: /sync now/i }); 189 + fireEvent.click(syncButton); 190 + 191 + await waitFor(() => { 192 + expect(syncPostsMock).toHaveBeenCalled(); 193 + }); 194 + 195 + // Should return to normal state after error 196 + expect(await screen.findByRole("button", { name: /sync now/i })).toBeEnabled(); 197 + }); 198 + 199 + it("disables buttons during operations", async () => { 200 + syncPostsMock.mockImplementation(() => new Promise(() => {})); 201 + 202 + render(() => <SyncStatusPanel did="did:plc:test" />); 203 + 204 + const syncButton = await screen.findByRole("button", { name: /sync now/i }); 205 + const reindexButton = screen.getByRole("button", { name: /reindex/i }); 206 + 207 + fireEvent.click(syncButton); 208 + 209 + await waitFor(() => { 210 + expect(syncButton).toBeDisabled(); 211 + expect(reindexButton).toBeDisabled(); 212 + }); 213 + }); 214 + });
+143 -84
src/components/search/SyncStatusPanel.tsx
··· 1 - import { getSyncStatus, syncPosts, type SyncStatus } from "$/lib/api/search"; 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { getSyncStatus, reindexEmbeddings, syncPosts, type SyncStatus } from "$/lib/api/search"; 3 + import { formatRelativeTime } from "$/lib/feeds"; 2 4 import * as logger from "@tauri-apps/plugin-log"; 3 - import { createSignal, onMount, Show } from "solid-js"; 5 + import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 4 6 import { Motion } from "solid-motionone"; 7 + import { PostCount } from "../shared/PostCount"; 5 8 6 - type SyncStatusPanelProps = { did: string }; 9 + function SourceStatusRow( 10 + props: { count: number; cursor?: string | null; isActive: boolean; source: "like" | "bookmark" }, 11 + ) { 12 + const label = createMemo(() => (props.source === "like" ? "Liked posts" : "Bookmarked posts")); 13 + 14 + return ( 15 + <div class="grid gap-1.5"> 16 + <div class="flex items-center justify-between gap-3 text-xs text-on-surface-variant"> 17 + <span>{label()}</span> 18 + <span>{props.count} synced</span> 19 + </div> 20 + 21 + <div class="h-1.5 overflow-hidden rounded-full bg-white/8"> 22 + <div 23 + class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim transition-opacity" 24 + classList={{ "animate-pulse": props.isActive }} 25 + style={{ width: props.count > 0 ? "100%" : "0%" }} /> 26 + </div> 27 + 28 + <Show when={props.cursor}> 29 + <p class="m-0 text-[0.68rem] text-on-surface-variant/70">Resume cursor saved for interrupted sync recovery.</p> 30 + </Show> 31 + </div> 32 + ); 33 + } 34 + 35 + function ReindexButton(props: { isSyncing: boolean; isReindexing: boolean; onReindex: () => void }) { 36 + return ( 37 + <button 38 + type="button" 39 + onClick={() => void props.onReindex()} 40 + disabled={props.isSyncing || props.isReindexing} 41 + class="inline-flex items-center gap-2 rounded-xl border-0 bg-white/6 px-3 py-2 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50"> 42 + <Show when={props.isReindexing} fallback={<Icon kind="refresh" />}> 43 + <Icon iconClass="i-ri-loader-4-line animate-spin" /> 44 + </Show> 45 + <Show when={props.isReindexing} fallback="Reindex">Reindexing...</Show> 46 + </button> 47 + ); 48 + } 49 + 50 + function SyncButton(props: { isSyncing: boolean; isReindexing: boolean; onSync: () => void }) { 51 + return ( 52 + <button 53 + type="button" 54 + onClick={() => void props.onSync()} 55 + disabled={props.isSyncing || props.isReindexing} 56 + class="inline-flex items-center gap-2 rounded-xl border-0 bg-white/6 px-3 py-2 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50"> 57 + <Show when={props.isSyncing} fallback={<Icon kind="refresh" />}> 58 + <Icon iconClass="i-ri-loader-4-line animate-spin" /> 59 + </Show> 60 + <Show when={props.isSyncing} fallback="Sync now">Syncing...</Show> 61 + </button> 62 + ); 63 + } 64 + 65 + type SyncStatusPanelProps = { did: string; onStatusChange?: (status: SyncStatus[]) => void }; 7 66 8 67 export function SyncStatusPanel(props: SyncStatusPanelProps) { 9 68 const [syncStatus, setSyncStatus] = createSignal<SyncStatus[]>([]); 10 69 const [isSyncing, setIsSyncing] = createSignal(false); 70 + const [isReindexing, setIsReindexing] = createSignal(false); 11 71 12 72 async function loadSyncStatus() { 13 73 try { 14 74 const status = await getSyncStatus(props.did); 15 75 setSyncStatus(status); 76 + props.onStatusChange?.(status); 16 77 } catch (error) { 17 78 logger.error("failed to load sync status", { keyValues: { error: String(error) } }); 18 79 } ··· 31 92 } 32 93 } 33 94 95 + async function handleReindex() { 96 + setIsReindexing(true); 97 + try { 98 + const count = await reindexEmbeddings(); 99 + logger.info("reindex complete", { keyValues: { count: String(count) } }); 100 + await loadSyncStatus(); 101 + } catch (error) { 102 + logger.error("reindex failed", { keyValues: { error: String(error) } }); 103 + } finally { 104 + setIsReindexing(false); 105 + } 106 + } 107 + 34 108 onMount(() => { 35 109 void loadSyncStatus(); 36 110 ··· 38 112 void loadSyncStatus(); 39 113 }, 60_000); 40 114 41 - return () => clearInterval(interval); 115 + onCleanup(() => clearInterval(interval)); 42 116 }); 43 117 44 - const totalPosts = () => syncStatus().reduce((sum, s) => sum + (s.post_count ?? 0), 0); 118 + const totalPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); 119 + const hasAnyPosts = createMemo(() => totalPosts() > 0); 120 + const lastSync = createMemo(() => { 121 + const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; 122 + if (timestamps.length === 0) { 123 + return null; 124 + } 45 125 46 - const lastSyncTime = () => { 47 - const times = syncStatus().map((s) => s.last_synced_at).filter(Boolean) as string[]; 48 - if (times.length === 0) return null; 49 - const latest = times.toSorted().toReversed()[0]; 126 + const latest = timestamps.toSorted((left, right) => right.localeCompare(left))[0]; 50 127 return formatRelativeTime(latest); 51 - }; 128 + }); 129 + const statusTone = createMemo(() => { 130 + if (isSyncing() || isReindexing()) { 131 + return { className: "bg-primary/15 text-primary", label: isReindexing() ? "Reindexing" : "Syncing" }; 132 + } 52 133 53 - return ( 54 - <Motion.div 55 - class="border-b border-white/5 px-6 py-3" 56 - initial={{ opacity: 0, height: 0 }} 57 - animate={{ opacity: 1, height: "auto" }} 58 - exit={{ opacity: 0, height: 0 }} 59 - transition={{ duration: 0.2 }}> 60 - <div class="flex items-center justify-between"> 61 - <StatusInfo isSyncing={isSyncing()} totalPosts={totalPosts()} lastSync={lastSyncTime()} /> 62 - <SyncButton isSyncing={isSyncing()} onSync={handleSync} /> 63 - </div> 64 - </Motion.div> 65 - ); 66 - } 134 + if (hasAnyPosts()) { 135 + return { className: "bg-emerald-400/15 text-emerald-300", label: "Ready" }; 136 + } 67 137 68 - function StatusInfo(props: { isSyncing: boolean; totalPosts: number; lastSync: string | null }) { 69 - return ( 70 - <div class="flex items-center gap-3"> 71 - <div class="flex items-center gap-2"> 72 - <StatusIndicator isSyncing={props.isSyncing} /> 73 - <span class="text-sm font-medium text-on-surface">{props.isSyncing ? "Syncing..." : "Active"}</span> 74 - </div> 138 + return { className: "bg-white/8 text-on-surface-variant", label: "Empty" }; 139 + }); 75 140 76 - <Show when={props.totalPosts > 0}> 77 - <span class="text-xs text-on-surface-variant"> 78 - <span class="font-medium text-primary">{props.totalPosts}</span> posts indexed 79 - </span> 80 - </Show> 81 - 82 - <Show when={props.lastSync}> 83 - {(time) => <span class="text-xs text-on-surface-variant">· Last sync: {time()}</span>} 84 - </Show> 85 - </div> 86 - ); 87 - } 88 - 89 - function StatusIndicator(props: { isSyncing: boolean }) { 90 141 return ( 91 - <Show when={props.isSyncing} fallback={<span class="flex h-2 w-2 rounded-full bg-green-500" />}> 92 - <span class="flex h-2 w-2 animate-pulse rounded-full bg-primary" /> 93 - </Show> 94 - ); 95 - } 142 + <section class="panel-surface grid gap-4 p-5"> 143 + <div class="flex flex-col items-start justify-between gap-4"> 144 + <div class="grid gap-1"> 145 + <div class="flex items-center gap-2"> 146 + <p class="m-0 text-sm font-medium text-on-surface">Sync Status</p> 147 + <span class={`rounded-full px-2 py-0.5 text-[0.68rem] font-medium ${statusTone().className}`}> 148 + {statusTone().label} 149 + </span> 150 + </div> 96 151 97 - function SyncButton(props: { isSyncing: boolean; onSync: () => void }) { 98 - return ( 99 - <button 100 - type="button" 101 - onClick={() => props.onSync()} 102 - disabled={props.isSyncing} 103 - class="inline-flex items-center gap-2 rounded-lg border-0 bg-white/5 px-3 py-1.5 text-xs font-medium text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface disabled:cursor-not-allowed disabled:opacity-50"> 104 - <span class="flex items-center"> 105 - <i classList={{ "i-ri-refresh-line": !props.isSyncing, "i-ri-loader-4-line animate-spin": props.isSyncing }} /> 106 - </span> 107 - <Show when={props.isSyncing} fallback={"Sync now"}>Syncing...</Show> 108 - </button> 109 - ); 110 - } 152 + <Show 153 + when={hasAnyPosts()} 154 + fallback={ 155 + <p class="m-0 text-xs text-on-surface-variant"> 156 + Local search is empty until likes or bookmarks are indexed. 157 + </p> 158 + }> 159 + <PostCount totalPosts={totalPosts()} lastSync={lastSync()} /> 160 + </Show> 161 + </div> 111 162 112 - function formatRelativeTime(value: string) { 113 - const timestamp = new Date(value).getTime(); 114 - if (Number.isNaN(timestamp)) { 115 - return ""; 116 - } 163 + <div class="flex items-center gap-2"> 164 + <Show when={hasAnyPosts()}> 165 + <ReindexButton isSyncing={isSyncing()} isReindexing={isReindexing()} onReindex={handleReindex} /> 166 + </Show> 117 167 118 - const deltaSeconds = Math.round((timestamp - Date.now()) / 1000); 119 - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); 120 - const ranges = [ 121 - ["year", 60 * 60 * 24 * 365], 122 - ["month", 60 * 60 * 24 * 30], 123 - ["day", 60 * 60 * 24], 124 - ["hour", 60 * 60], 125 - ["minute", 60], 126 - ] as const; 168 + <SyncButton isSyncing={isSyncing()} isReindexing={isReindexing()} onSync={handleSync} /> 169 + </div> 170 + </div> 127 171 128 - for (const [unit, seconds] of ranges) { 129 - if (Math.abs(deltaSeconds) >= seconds) { 130 - return formatter.format(Math.round(deltaSeconds / seconds), unit); 131 - } 132 - } 172 + <Show when={isSyncing() || isReindexing()}> 173 + <div class="h-1.5 overflow-hidden rounded-full bg-white/8"> 174 + <Motion.div 175 + class="h-full w-2/3 rounded-full bg-linear-to-r from-primary to-primary-dim" 176 + animate={{ x: ["-40%", "120%"] }} 177 + transition={{ duration: isReindexing() ? 1.8 : 1.1, repeat: Infinity, easing: "linear" }} /> 178 + </div> 179 + </Show> 133 180 134 - return formatter.format(deltaSeconds, "second"); 181 + <div class="grid gap-3"> 182 + <For each={syncStatus()}> 183 + {(status) => ( 184 + <SourceStatusRow 185 + count={status.postCount ?? 0} 186 + isActive={isSyncing()} 187 + source={status.source} 188 + cursor={status.cursor} /> 189 + )} 190 + </For> 191 + </div> 192 + </section> 193 + ); 135 194 }
+5 -1
src/components/shared/Icon.tsx
··· 30 30 | "danger" 31 31 | "repost" 32 32 | "reply" 33 - | "follow"; 33 + | "follow" 34 + | "download"; 34 35 35 36 type IconProps = JSX.HTMLAttributes<HTMLSpanElement> & { 36 37 class?: string; ··· 128 129 </Match> 129 130 <Match when={local.kind === "follow"}> 130 131 <i class="i-ri-user-add-line" /> 132 + </Match> 133 + <Match when={local.kind === "download"}> 134 + <i class="i-ri-download-cloud-line" /> 131 135 </Match> 132 136 </Switch> 133 137 </span>
+45
src/components/shared/PostCount.tsx
··· 1 + import { Show } from "solid-js"; 2 + 3 + export function PostCount(props: { totalPosts: number; lastSync?: string | null; inline?: boolean }) { 4 + return ( 5 + <Show when={props.inline} fallback={<BlockPostCount totalPosts={props.totalPosts} lastSync={props.lastSync} />}> 6 + <InlinePostCount totalPosts={props.totalPosts} lastSync={props.lastSync} /> 7 + </Show> 8 + ); 9 + } 10 + 11 + function InlinePostCount(props: { totalPosts: number; lastSync?: string | null }) { 12 + return ( 13 + <span class="inline-flex items-center gap-1 text-xs text-on-surface-variant"> 14 + <span class="font-medium text-primary">{props.totalPosts}</span> 15 + <span>Indexed</span> 16 + <Show when={props.lastSync}> 17 + {(value) => ( 18 + <> 19 + <span>·</span> 20 + <span>Sync {value()}</span> 21 + </> 22 + )} 23 + </Show> 24 + </span> 25 + ); 26 + } 27 + 28 + function BlockPostCount(props: { totalPosts: number; lastSync?: string | null }) { 29 + return ( 30 + <p class="text-xs text-on-surface-variant"> 31 + <span class="inline-flex items-center gap-2"> 32 + <span class="font-medium text-primary">{props.totalPosts}</span> 33 + <span>posts indexed</span> 34 + <Show when={props.lastSync}> 35 + {(value) => ( 36 + <> 37 + <span>·</span> 38 + <span>Last sync {value()}</span> 39 + </> 40 + )} 41 + </Show> 42 + </span> 43 + </p> 44 + ); 45 + }
+41 -21
src/lib/api/search.ts
··· 10 10 actors: { did: string; handle: string; displayName?: string | null; avatar?: string | null }[]; 11 11 }; 12 12 13 - export type StarterPackSearchResult = { 14 - cursor?: string | null; 15 - starterPacks: { 16 - uri: string; 17 - cid: string; 18 - record: { name: string; description?: string; createdAt: string }; 19 - creator: { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 20 - indexedAt: string; 21 - }[]; 13 + type TStarterPack = { 14 + uri: string; 15 + cid: string; 16 + record: { name: string; description?: string; createdAt: string }; 17 + creator: { did: string; handle: string; displayName?: string | null; avatar?: string | null }; 18 + indexedAt: string; 22 19 }; 23 20 21 + export type StarterPackSearchResult = { cursor?: string | null; starterPacks: Array<TStarterPack> }; 22 + 23 + type TPostSource = "like" | "bookmark"; 24 + 24 25 export type LocalPostResult = { 25 26 uri: string; 26 27 cid: string; 27 - author_did: string; 28 - author_handle: string; 29 - text: string; 30 - created_at: string; 31 - indexed_at: string; 32 - source: "like" | "bookmark"; 33 - score?: number; 28 + authorDid: string; 29 + authorHandle?: string | null; 30 + text?: string | null; 31 + createdAt?: string | null; 32 + source: TPostSource; 33 + score: number; 34 + keywordMatch: boolean; 35 + semanticMatch: boolean; 34 36 }; 35 37 36 38 export type SyncStatus = { 37 39 did: string; 38 - source: "like" | "bookmark"; 40 + source: TPostSource; 39 41 cursor?: string | null; 40 - last_synced_at?: string | null; 41 - post_count?: number; 42 + lastSyncedAt?: string | null; 43 + postCount?: number; 42 44 }; 43 45 44 46 export type EmbeddingsConfig = { 45 47 enabled: boolean; 46 - model_name: string; 48 + modelName: string; 47 49 dimensions: number; 48 50 downloaded: boolean; 49 - download_progress?: number; 51 + downloadActive: boolean; 52 + downloadProgress?: number | null; 53 + downloadEtaSeconds?: number | null; 54 + downloadFile?: string | null; 55 + downloadFileIndex?: number | null; 56 + downloadFileTotal?: number | null; 57 + lastError?: string | null; 50 58 }; 51 59 52 60 export function searchPostsNetwork( ··· 93 101 export function setEmbeddingsEnabled(enabled: boolean): Promise<void> { 94 102 return invoke("set_embeddings_enabled", { enabled }); 95 103 } 104 + 105 + export function getEmbeddingsEnabled(): Promise<boolean> { 106 + return invoke("get_embeddings_enabled"); 107 + } 108 + 109 + export function getEmbeddingsConfig(): Promise<EmbeddingsConfig> { 110 + return invoke("get_embeddings_config"); 111 + } 112 + 113 + export function prepareEmbeddingsModel(): Promise<EmbeddingsConfig> { 114 + return invoke("prepare_embeddings_model"); 115 + }
+18
src/lib/utils/text.ts
··· 23 23 return String(err); 24 24 } 25 25 } 26 + 27 + export function formatEtaSeconds(value: number) { 28 + if (value < 60) { 29 + return `${value}s`; 30 + } 31 + 32 + const minutes = Math.floor(value / 60); 33 + const seconds = value % 60; 34 + return `${minutes}m ${seconds}s`; 35 + } 36 + 37 + export function formatProgress(value: number | null | undefined) { 38 + if (typeof value !== "number" || Number.isNaN(value)) { 39 + return "Pending"; 40 + } 41 + 42 + return `${Math.round(value)}%`; 43 + }
+10 -5
src/router.test.tsx
··· 17 17 const renderNotifications = vi.fn((currentSession: ActiveSession) => ( 18 18 <div data-testid="notifications-view">{currentSession.handle}</div> 19 19 )); 20 - const renderTimeline = vi.fn((currentSession: ActiveSession, context: { threadUri: string | null }) => ( 20 + const renderTimeline = vi.fn(( 21 + props: { 22 + session: ActiveSession; 23 + context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }; 24 + }, 25 + ) => ( 21 26 <div data-testid="timeline-view"> 22 - <span>{currentSession.handle}</span> 23 - <span>{context.threadUri ?? "no-thread"}</span> 27 + <span>{props.session.handle}</span> 28 + <span>{props.context.threadUri ?? "no-thread"}</span> 24 29 </div> 25 30 )); 26 31 ··· 46 51 await screen.findByTestId("timeline-view"); 47 52 48 53 expect(renderTimeline).toHaveBeenCalled(); 49 - expect(renderTimeline.mock.lastCall?.[1].threadUri).toBeNull(); 54 + expect(renderTimeline.mock.lastCall?.[0].context.threadUri).toBeNull(); 50 55 expect(screen.getByText("no-thread")).toBeInTheDocument(); 51 56 }); 52 57 ··· 56 61 57 62 await screen.findByTestId("timeline-view"); 58 63 59 - expect(renderTimeline.mock.lastCall?.[1].threadUri).toBe(threadUri); 64 + expect(renderTimeline.mock.lastCall?.[0].context.threadUri).toBe(threadUri); 60 65 expect(screen.getByText(threadUri)).toBeInTheDocument(); 61 66 }); 62 67