diff --git a/docs/designs/search.html b/docs/designs/search.html index e539490..a035577 100644 --- a/docs/designs/search.html +++ b/docs/designs/search.html @@ -151,7 +151,7 @@
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f9c4f3a..f8f2181 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -983,6 +983,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1027,10 +1039,29 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cordyceps" version = "0.3.4" @@ -1602,6 +1633,15 @@ dependencies = [ "const-random", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1923,7 +1963,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3688aa7e02113db24e0f83aba1edee912f36f515b52cffc9b3c550bbfc3eab87" dependencies = [ "anyhow", - "hf-hub", + "hf-hub 0.4.3", "image", "ndarray", "ort", @@ -2126,6 +2166,21 @@ dependencies = [ "new_debug_unreachable", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-buffered" version = "0.2.13" @@ -2146,6 +2201,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -2213,6 +2269,7 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -2775,7 +2832,7 @@ checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ "dirs", "http", - "indicatif", + "indicatif 0.17.11", "libc", "log", "native-tls", @@ -2788,6 +2845,30 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "hf-hub" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +dependencies = [ + "dirs", + "futures", + "http", + "indicatif 0.18.4", + "libc", + "log", + "native-tls", + "num_cpus", + "rand 0.9.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "ureq 3.3.0", + "windows-sys 0.61.2", +] + [[package]] name = "hickory-proto" version = "0.24.4" @@ -3235,13 +3316,26 @@ version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ - "console", + "console 0.15.11", "number_prefix", "portable-atomic", "unicode-width 0.2.2", "web-time", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console 0.16.3", + "portable-atomic", + "unicode-width 0.2.2", + "unit-prefix", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -3757,6 +3851,7 @@ name = "lazurite-desktop" version = "0.1.0" dependencies = [ "fastembed", + "hf-hub 0.5.0", "jacquard", "reqwest 0.12.28", "rusqlite", @@ -3892,6 +3987,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -8123,6 +8224,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsigned-varint" version = "0.8.0" @@ -8162,15 +8269,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" dependencies = [ "base64 0.22.1", + "cookie_store", "der 0.8.0", + "flate2", "log", "native-tls", "percent-encoding", + "rustls", "rustls-pki-types", + "serde", + "serde_json", "socks", "ureq-proto", "utf8-zero", "webpki-root-certs", + "webpki-roots 1.0.6", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 46dd7d6..169a55d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ rusqlite = { version = "0.37.0", features = ["bundled"] } jacquard = "0.11.0" sqlite-vec = "0.1.7" fastembed = "5.13.0" +hf-hub = "0.5" tokio = "1.50.0" tauri-plugin-deep-link = "2" tauri-plugin-log = "2" diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index 9b6f712..15dc279 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -17,7 +17,7 @@ pub async fn search_posts_network( pub fn search_posts( query: String, mode: String, limit: u32, app: AppHandle, state: State<'_, AppState>, ) -> Result, AppError> { - search::search_posts(query, mode, limit, &app, &state) + search::search_posts(&query, &mode, limit, &app, &state) } #[tauri::command] @@ -58,3 +58,20 @@ pub fn reindex_embeddings(app: AppHandle, state: State<'_, AppState>) -> Result< pub fn set_embeddings_enabled(enabled: bool, state: State<'_, AppState>) -> Result<(), AppError> { search::set_embeddings_enabled(enabled, &state) } + +#[tauri::command] +pub fn get_embeddings_enabled(state: State<'_, AppState>) -> Result { + search::get_embeddings_enabled(&state) +} + +#[tauri::command] +pub fn get_embeddings_config(app: AppHandle, state: State<'_, AppState>) -> Result { + search::get_embeddings_config(&app, &state) +} + +#[tauri::command] +pub fn prepare_embeddings_model( + app: AppHandle, state: State<'_, AppState>, +) -> Result { + search::prepare_embeddings_model(&app, &state) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d7091dc..d9a8926 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -108,7 +108,10 @@ pub fn run() { cmd::search::get_sync_status, cmd::search::embed_pending_posts, cmd::search::reindex_embeddings, - cmd::search::set_embeddings_enabled + cmd::search::set_embeddings_enabled, + cmd::search::get_embeddings_enabled, + cmd::search::get_embeddings_config, + cmd::search::prepare_embeddings_model ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 6e701ad..56c3db0 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -2,7 +2,10 @@ use super::auth::LazuriteOAuthSession; use super::error::{AppError, Result}; use super::state::AppState; use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions}; +use hf_hub::api::{sync::ApiBuilder, Progress}; +use hf_hub::Cache; use jacquard::api::app_bsky::actor::search_actors::SearchActors; +use jacquard::api::app_bsky::bookmark::get_bookmarks::GetBookmarks; use jacquard::api::app_bsky::feed::get_actor_likes::GetActorLikes; use jacquard::api::app_bsky::feed::search_posts::SearchPosts; use jacquard::api::app_bsky::graph::search_starter_packs::SearchStarterPacks; @@ -12,19 +15,32 @@ use jacquard::xrpc::XrpcClient; use rusqlite::{params, Connection, OptionalExtension}; use serde::Serialize; use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Manager}; use tauri_plugin_log::log; const DEFAULT_RRF_K: f64 = 60.0; +const EMBEDDING_MODEL_NAME: &str = "nomic-embed-text-v1.5"; +const EMBEDDING_MODEL_REPO: &str = "nomic-ai/nomic-embed-text-v1.5"; +const EMBEDDING_MODEL_FILE: &str = "onnx/model.onnx"; +const EMBEDDING_TOKENIZER_FILES: &[&str] = &[ + "config.json", + "special_tokens_map.json", + "tokenizer.json", + "tokenizer_config.json", +]; +const EMBEDDING_DIMENSIONS: i64 = 768; const SEARCH_SYNC_CHECK_INTERVAL: Duration = Duration::from_secs(5); const SEARCH_SYNC_INTERVAL: Duration = Duration::from_secs(15 * 60); +static EMBEDDINGS_DOWNLOAD_STATE: LazyLock> = + LazyLock::new(|| Mutex::new(EmbeddingsDownloadState::default())); #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncStatus { + pub did: String, pub source: String, pub post_count: i64, pub cursor: Option, @@ -42,6 +58,8 @@ pub struct PostResult { pub created_at: Option, pub source: String, pub score: f64, + pub keyword_match: bool, + pub semantic_match: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -57,6 +75,57 @@ struct SearchRow { post: PostResult, } +#[derive(Clone, Debug, Default)] +struct EmbeddingsDownloadState { + active: bool, + current_file: Option, + downloaded_files: usize, + total_files: usize, + current_bytes: usize, + current_total_bytes: usize, + started_at: Option, + last_error: Option, +} + +struct ModelDownloadProgress { + file_index: usize, + total_files: usize, +} + +impl ModelDownloadProgress { + fn new(file_index: usize, total_files: usize) -> Self { + Self { file_index, total_files } + } +} + +impl Progress for ModelDownloadProgress { + fn init(&mut self, size: usize, filename: &str) { + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { + state.active = true; + state.current_file = Some(filename.to_owned()); + state.downloaded_files = self.file_index; + state.total_files = self.total_files; + state.current_bytes = 0; + state.current_total_bytes = size; + state.started_at = Some(Instant::now()); + state.last_error = None; + } + } + + fn update(&mut self, size: usize) { + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { + state.current_bytes = state.current_bytes.saturating_add(size); + } + } + + fn finish(&mut self) { + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { + state.downloaded_files = self.file_index + 1; + state.current_bytes = state.current_total_bytes; + } + } +} + fn validate_query(query: &str) -> Result<()> { if query.trim().is_empty() { return Err(AppError::validation("search query must not be empty")); @@ -159,11 +228,20 @@ fn db_save_sync_state(conn: &Connection, did: &str, source: &str, cursor: Option Ok(()) } +fn db_post_exists(conn: &Connection, storage_key: &str) -> Result { + conn.query_row( + "SELECT 1 FROM posts WHERE storage_key = ?1", + params![storage_key], + |_| Ok(()), + ) + .optional() + .map(|row| row.is_some()) + .map_err(AppError::from) +} + /// Upsert a single `FeedViewPost` JSON item into the `posts` table. /// On conflict (same uri) updates mutable fields but keeps indexed_at. -fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Value, source: &str) -> Result<()> { - let post = feed_item.get("post").unwrap_or(feed_item); - +fn db_upsert_post_value(conn: &Connection, owner_did: &str, post: &serde_json::Value, source: &str) -> Result { let uri = post .get("uri") .and_then(|v| v.as_str()) @@ -186,6 +264,7 @@ fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Va let created_at = record.and_then(|r| r.get("createdAt")).and_then(|v| v.as_str()); let json_record = record.map(|r| r.to_string()); let storage_key = storage_key(owner_did, source, uri); + let inserted = !db_post_exists(conn, &storage_key)?; conn.execute( "INSERT INTO posts(storage_key, owner_did, uri, cid, author_did, author_handle, text, created_at, json_record, source) @@ -209,7 +288,27 @@ fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Va source ], )?; - Ok(()) + Ok(inserted) +} + +fn db_upsert_post(conn: &Connection, owner_did: &str, feed_item: &serde_json::Value, source: &str) -> Result { + let post = feed_item.get("post").unwrap_or(feed_item); + let kind = post.get("$type").and_then(|value| value.as_str()); + match kind { + Some("app.bsky.feed.defs#blockedPost" | "app.bsky.feed.defs#notFoundPost") => Ok(true), + _ => db_upsert_post_value(conn, owner_did, post, source), + } +} + +fn db_upsert_bookmark(conn: &Connection, owner_did: &str, bookmark: &serde_json::Value) -> Result { + let item = bookmark + .get("item") + .ok_or_else(|| AppError::validation("bookmark item missing item payload"))?; + let kind = item.get("$type").and_then(|value| value.as_str()); + match kind { + Some("app.bsky.feed.defs#blockedPost" | "app.bsky.feed.defs#notFoundPost") => Ok(true), + _ => db_upsert_post_value(conn, owner_did, item, "bookmark"), + } } fn db_post_count(conn: &Connection, owner_did: &str, source: &str) -> Result { @@ -232,7 +331,7 @@ fn db_sync_status(conn: &Connection, did: &str, source: &str) -> Result Result { validate_source(&source)?; - - if source == "bookmark" { - return Err(AppError::validation("bookmark sync is not yet supported")); - } - let session = get_session(state).await?; let mut cursor: Option = { let conn = state.auth_store.lock_connection()?; db_load_sync_cursor(&conn, &did, &source)? }; + let resuming = cursor.is_some(); log::info!("starting {source} sync for {did}, resume cursor: {cursor:?}"); loop { - let output = session - .send( - GetActorLikes::new() - .limit(Some(100i64)) - .cursor(cursor.as_deref().map(|c| c.into())) - .actor(AtIdentifier::Did(Did::new(&did)?)) - .build(), - ) - .await - .map_err(|error| { - log::error!("getActorLikes error: {error}"); - AppError::validation("getActorLikes error") - })? - .into_output() - .map_err(|error| { - log::error!("getActorLikes output error: {error}"); - AppError::validation("getActorLikes output error") - })?; - - let output_json = serde_json::to_value(&output)?; - - let feed = output_json - .get("feed") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - - if feed.is_empty() { - log::info!("{source} sync for {did}: empty page, stopping"); - break; - } - - let next_cursor = output_json.get("cursor").and_then(|v| v.as_str()).map(str::to_owned); + let (items, next_cursor) = match source.as_str() { + "like" => { + let output = session + .send( + GetActorLikes::new() + .limit(Some(100i64)) + .cursor(cursor.as_deref().map(|value| value.into())) + .actor(AtIdentifier::Did(Did::new(&did)?)) + .build(), + ) + .await + .map_err(|error| { + log::error!("getActorLikes error: {error}"); + AppError::validation("getActorLikes error") + })? + .into_output() + .map_err(|error| { + log::error!("getActorLikes output error: {error}"); + AppError::validation("getActorLikes output error") + })?; + let output_json = serde_json::to_value(&output)?; + let feed = output_json + .get("feed") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let next = output_json + .get("cursor") + .and_then(|value| value.as_str()) + .map(str::to_owned); + (feed, next) + } + "bookmark" => { + let output = session + .send( + GetBookmarks::new() + .limit(Some(100i64)) + .cursor(cursor.as_deref().map(|value| value.into())) + .build(), + ) + .await + .map_err(|error| { + log::error!("getBookmarks error: {error}"); + AppError::validation("getBookmarks error") + })? + .into_output() + .map_err(|error| { + log::error!("getBookmarks output error: {error}"); + AppError::validation("getBookmarks output error") + })?; + let output_json = serde_json::to_value(&output)?; + let bookmarks = output_json + .get("bookmarks") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + let next = output_json + .get("cursor") + .and_then(|value| value.as_str()) + .map(str::to_owned); + (bookmarks, next) + } + _ => unreachable!(), + }; { let conn = state.auth_store.lock_connection()?; - for item in &feed { - db_upsert_post(&conn, &did, item, &source)?; + if items.is_empty() { + db_save_sync_state(&conn, &did, &source, None)?; + log::info!("{source} sync for {did}: empty page, stopping"); + break; } - db_save_sync_state(&conn, &did, &source, next_cursor.as_deref())?; - } - log::debug!( - "{source} sync for {did}: upserted {} posts, next cursor: {next_cursor:?}", - feed.len() - ); + let mut inserted_count = 0usize; + let mut existing_count = 0usize; + + for item in &items { + let inserted = match source.as_str() { + "like" => db_upsert_post(&conn, &did, item, &source)?, + "bookmark" => db_upsert_bookmark(&conn, &did, item)?, + _ => unreachable!(), + }; + + if inserted { + inserted_count += 1; + } else { + existing_count += 1; + } + } + + let stop_after_page = !resuming && existing_count > 0; + let cursor_to_store = if stop_after_page { None } else { next_cursor.as_deref() }; + db_save_sync_state(&conn, &did, &source, cursor_to_store)?; + + log::debug!( + "{source} sync for {did}: processed {} item(s), inserted {}, existing {}, next cursor: {next_cursor:?}", + items.len(), + inserted_count, + existing_count + ); + + if stop_after_page { + log::info!("{source} sync for {did}: reached previously indexed items, stopping"); + break; + } + } match next_cursor { None => { log::info!("{source} sync for {did}: reached end of feed"); break; } - Some(c) => cursor = Some(c), + Some(next) => cursor = Some(next), } } @@ -413,6 +568,7 @@ pub fn get_sync_status(did: &str, state: &AppState) -> Result> { const EMBED_BATCH_SIZE: usize = 32; fn build_embedding_model(models_dir: PathBuf) -> Result { + ensure_model_downloaded(&models_dir)?; TextEmbedding::try_new( TextInitOptions::new(EmbeddingModel::NomicEmbedTextV15) .with_cache_dir(models_dir) @@ -431,6 +587,87 @@ fn resolve_models_dir(app: &AppHandle) -> Result { Ok(dir) } +fn required_embedding_files() -> Vec<&'static str> { + let mut files = vec![EMBEDDING_MODEL_FILE]; + files.extend(EMBEDDING_TOKENIZER_FILES); + files +} + +fn cached_embedding_files(models_dir: &Path) -> usize { + let cache = Cache::new(models_dir.to_path_buf()); + let repo = cache.model(EMBEDDING_MODEL_REPO.to_owned()); + required_embedding_files() + .into_iter() + .filter(|filename| repo.get(filename).is_some()) + .count() +} + +fn embeddings_downloaded(models_dir: &Path) -> bool { + cached_embedding_files(models_dir) == required_embedding_files().len() +} + +fn set_download_idle_state(downloaded_files: usize, total_files: usize) { + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { + state.active = false; + state.current_file = None; + state.downloaded_files = downloaded_files; + state.total_files = total_files; + state.current_bytes = 0; + state.current_total_bytes = 0; + state.started_at = None; + state.last_error = None; + } +} + +fn set_download_error(message: String) { + if let Ok(mut state) = EMBEDDINGS_DOWNLOAD_STATE.lock() { + state.active = false; + state.current_file = None; + state.current_bytes = 0; + state.current_total_bytes = 0; + state.started_at = None; + state.last_error = Some(message); + } +} + +fn ensure_model_downloaded(models_dir: &Path) -> Result<()> { + let required_files = required_embedding_files(); + let total_files = required_files.len(); + let already_cached = cached_embedding_files(models_dir); + if already_cached == total_files { + set_download_idle_state(total_files, total_files); + return Ok(()); + } + + set_download_idle_state(already_cached, total_files); + + let api = ApiBuilder::new() + .with_cache_dir(models_dir.to_path_buf()) + .with_progress(false) + .build() + .map_err(|error| AppError::validation(format!("failed to initialize embeddings downloader: {error}")))?; + let repo = api.model(EMBEDDING_MODEL_REPO.to_owned()); + let cache = Cache::new(models_dir.to_path_buf()); + let cache_repo = cache.model(EMBEDDING_MODEL_REPO.to_owned()); + + for (index, filename) in required_files.iter().enumerate() { + if cache_repo.get(filename).is_some() { + set_download_idle_state(index + 1, total_files); + continue; + } + + let download = repo.download_with_progress(filename, ModelDownloadProgress::new(index, total_files)); + if let Err(error) = download { + let message = format!("failed to download embeddings file {filename}: {error}"); + set_download_error(message.clone()); + return Err(AppError::validation(message)); + } + } + + set_download_idle_state(total_files, total_files); + Ok(()) +} + fn db_get_embeddings_enabled(conn: &Connection) -> Result { let val: Option = conn .query_row( @@ -521,6 +758,8 @@ fn search_row_from_keyword_row(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result, semantic_rows: Vec, limit: .entry(row.storage_key.clone()) .and_modify(|value| *value += score) .or_insert(score); - fused.entry(row.storage_key.clone()).or_insert(row); + fused + .entry(row.storage_key.clone()) + .and_modify(|existing| { + existing.post.keyword_match |= row.post.keyword_match; + existing.post.semantic_match |= row.post.semantic_match; + }) + .or_insert(row); } } @@ -699,9 +946,7 @@ fn embed_posts(posts: &[(String, String)], models_dir: PathBuf, state: &AppState Ok(total) } -pub fn search_posts( - query: String, mode: String, limit: u32, app: &AppHandle, state: &AppState, -) -> Result> { +pub fn search_posts(query: &str, mode: &str, limit: u32, app: &AppHandle, state: &AppState) -> Result> { validate_query(&query)?; let limit = validate_limit(limit)?; let mode = validate_search_mode(&mode)?; @@ -783,6 +1028,90 @@ pub fn set_embeddings_enabled(enabled: bool, state: &AppState) -> Result<()> { db_set_embeddings_enabled(&conn, enabled) } +/// Get the current embeddings-enabled preference. +pub fn get_embeddings_enabled(state: &AppState) -> Result { + let conn = state.auth_store.lock_connection()?; + db_get_embeddings_enabled(&conn) +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EmbeddingsConfig { + pub enabled: bool, + pub model_name: String, + pub dimensions: i64, + pub downloaded: bool, + pub download_active: bool, + pub download_progress: Option, + pub download_eta_seconds: Option, + pub download_file: Option, + pub download_file_index: Option, + pub download_file_total: Option, + pub last_error: Option, +} + +/// Get the embeddings configuration. +pub fn get_embeddings_config(app: &AppHandle, state: &AppState) -> Result { + let conn = state.auth_store.lock_connection()?; + let enabled = db_get_embeddings_enabled(&conn)?; + let models_dir = resolve_models_dir(app)?; + let downloaded = embeddings_downloaded(&models_dir); + let state = EMBEDDINGS_DOWNLOAD_STATE + .lock() + .map_err(|_| AppError::StatePoisoned("embeddings_download_state"))?; + let download_progress = if state.active && state.current_total_bytes > 0 { + Some((state.current_bytes as f64 / state.current_total_bytes as f64) * 100.0) + } else if downloaded { + Some(100.0) + } else { + None + }; + let download_eta_seconds = if state.active { + state.started_at.and_then(|started_at| { + let elapsed = started_at.elapsed().as_secs_f64(); + let current = state.current_bytes as f64; + let total = state.current_total_bytes as f64; + if elapsed <= 0.0 || current <= 0.0 || total <= current { + None + } else { + let bytes_per_second = current / elapsed; + let remaining = total - current; + Some((remaining / bytes_per_second).ceil() as u64) + } + }) + } else { + None + }; + + Ok(EmbeddingsConfig { + enabled, + model_name: EMBEDDING_MODEL_NAME.to_string(), + dimensions: EMBEDDING_DIMENSIONS, + downloaded, + download_active: state.active, + download_progress, + download_eta_seconds, + download_file: state.current_file.clone(), + download_file_index: state.active.then_some(state.downloaded_files + 1), + download_file_total: (state.total_files > 0).then_some(state.total_files), + last_error: state.last_error.clone(), + }) +} + +pub fn prepare_embeddings_model(app: &AppHandle, state: &AppState) -> Result { + let enabled = { + let conn = state.auth_store.lock_connection()?; + db_get_embeddings_enabled(&conn)? + }; + + if enabled { + let models_dir = resolve_models_dir(app)?; + ensure_model_downloaded(&models_dir)?; + } + + get_embeddings_config(app, state) +} + fn sync_due(active_did: Option<&str>, last_synced_did: Option<&str>, last_synced_at: Option) -> bool { match active_did { None => false, @@ -820,12 +1149,15 @@ pub fn spawn_search_sync_task(app: AppHandle) { if sync_due(active_did.as_deref(), last_synced_did.as_deref(), last_synced_at) { let did = active_did.clone().unwrap_or_default(); - match sync_posts(did.clone(), "like".to_owned(), &state).await { - Ok(status) => { + let like_sync = sync_posts(did.clone(), "like".to_owned(), &state).await; + let bookmark_sync = sync_posts(did.clone(), "bookmark".to_owned(), &state).await; + match (like_sync, bookmark_sync) { + (Ok(like_status), Ok(bookmark_status)) => { log::info!( - "background search sync complete for {} likes: {} post(s)", + "background search sync complete for {} likes/bookmarks: {}/{} post(s)", did, - status.post_count + like_status.post_count, + bookmark_status.post_count ); if let Err(error) = embed_pending_posts(&app, &state) { log::warn!("background embedding pass failed for {did}: {error}"); @@ -833,7 +1165,7 @@ pub fn spawn_search_sync_task(app: AppHandle) { last_synced_did = Some(did); last_synced_at = Some(Instant::now()); } - Err(error) => { + (Err(error), _) | (_, Err(error)) => { log::warn!("background search sync failed: {error}"); } } diff --git a/src/App.tsx b/src/App.tsx index c70949b..e5941d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -332,7 +332,7 @@ function App() { activeHandle={session.handle} onError={(message) => setApp("errorMessage", message)} /> )} - renderTimeline={(session, context) => ( + renderTimeline={({ session, context }) => ( - +
); diff --git a/src/components/search/EmbeddingsSettings.test.tsx b/src/components/search/EmbeddingsSettings.test.tsx new file mode 100644 index 0000000..bb59a63 --- /dev/null +++ b/src/components/search/EmbeddingsSettings.test.tsx @@ -0,0 +1,154 @@ +import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EmbeddingsSettings } from "./EmbeddingsSettings"; + +const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); +const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); +const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); + +vi.mock( + "$/lib/api/search", + () => ({ + getEmbeddingsConfig: getEmbeddingsConfigMock, + prepareEmbeddingsModel: prepareEmbeddingsModelMock, + setEmbeddingsEnabled: setEmbeddingsEnabledMock, + }), +); + +vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +describe("EmbeddingsSettings", () => { + beforeEach(() => { + getEmbeddingsConfigMock.mockReset(); + prepareEmbeddingsModelMock.mockReset(); + setEmbeddingsEnabledMock.mockReset(); + + getEmbeddingsConfigMock.mockResolvedValue({ + enabled: true, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: true, + downloadActive: false, + }); + prepareEmbeddingsModelMock.mockResolvedValue({ + enabled: true, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: true, + downloadActive: false, + }); + setEmbeddingsEnabledMock.mockResolvedValue(void 0); + }); + + it("renders embeddings settings with model info", async () => { + render(() => ); + + expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); + expect(await screen.findByText(/nomic-embed-text-v1\.5/)).toBeInTheDocument(); + expect(await screen.findByText(/768D/)).toBeInTheDocument(); + }); + + it("shows toggle in enabled state when embeddings are enabled", async () => { + render(() => ); + + const toggle = await screen.findByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "true"); + }); + + it("shows toggle in disabled state when embeddings are disabled", async () => { + getEmbeddingsConfigMock.mockResolvedValue({ + enabled: false, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: false, + downloadActive: false, + }); + + render(() => ); + + const toggle = await screen.findByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "false"); + }); + + it("toggles embeddings when clicking the switch", async () => { + getEmbeddingsConfigMock.mockResolvedValueOnce({ + enabled: true, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: true, + downloadActive: false, + }).mockResolvedValueOnce({ + enabled: false, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: false, + downloadActive: false, + }); + + render(() => ); + + const toggle = await screen.findByRole("switch"); + expect(toggle).toHaveAttribute("aria-checked", "true"); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(setEmbeddingsEnabledMock).toHaveBeenCalledWith(false); + }); + + await waitFor(() => { + expect(toggle).toHaveAttribute("aria-checked", "false"); + }); + }); + + it("shows download progress when model is not downloaded", async () => { + getEmbeddingsConfigMock.mockResolvedValue({ + enabled: true, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: false, + downloadActive: true, + downloadProgress: 0, + downloadFile: "onnx/model.onnx", + downloadFileIndex: 1, + downloadFileTotal: 5, + }); + + render(() => ); + + expect(await screen.findAllByText(/downloading model files/i)).toHaveLength(2); + expect(await screen.findByText(/0%/)).toBeInTheDocument(); + }); + + it("displays semantic search description", async () => { + render(() => ); + expect(await screen.findByText(/conceptually similar posts/i)).toBeInTheDocument(); + }); + + it("handles errors when loading config gracefully", async () => { + getEmbeddingsConfigMock.mockRejectedValue(new Error("Failed to load")); + + render(() => ); + + // Should still render without crashing + await waitFor(() => { + expect(getEmbeddingsConfigMock).toHaveBeenCalled(); + }); + }); + + it("handles errors when toggling gracefully", async () => { + setEmbeddingsEnabledMock.mockRejectedValue(new Error("Failed to save")); + + render(() => ); + + const toggle = await screen.findByRole("switch"); + fireEvent.click(toggle); + + await waitFor(() => { + expect(setEmbeddingsEnabledMock).toHaveBeenCalled(); + }); + + // Toggle state should remain unchanged on error + expect(toggle).toHaveAttribute("aria-checked", "true"); + }); +}); diff --git a/src/components/search/EmbeddingsSettings.tsx b/src/components/search/EmbeddingsSettings.tsx new file mode 100644 index 0000000..f984cbb --- /dev/null +++ b/src/components/search/EmbeddingsSettings.tsx @@ -0,0 +1,289 @@ +/* eslint react/jsx-max-depth: ["error", { "max": 5 }] */ +import { Icon } from "$/components/shared/Icon"; +import type { EmbeddingsConfig } from "$/lib/api/search"; +import { getEmbeddingsConfig, prepareEmbeddingsModel, setEmbeddingsEnabled } from "$/lib/api/search"; +import { formatEtaSeconds, formatProgress } from "$/lib/utils/text"; +import * as logger from "@tauri-apps/plugin-log"; +import { createEffect, createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; +import { Motion, Presence } from "solid-motionone"; + +function ModelDescriptor(props: { config: EmbeddingsConfig | null }) { + return ( +

+ {props.config?.modelName ?? "nomic-embed-text-v1.5"} + · + {props.config?.dimensions ?? 768}D +

+ ); +} + +function EmbedSettingsHeader(props: { config: EmbeddingsConfig | null; isLoading: boolean; handleToggle: () => void }) { + return ( +
+
+ + +

Semantic Search

+
+ + + {(current) => ( + void props.handleToggle()} /> + )} + +
+ ); +} + +function DownloadButton(props: { config: EmbeddingsConfig | null; prepareModel: () => Promise }) { + return ( + + ); +} + +function ToggleSwitch(props: { checked: boolean; disabled?: boolean; onChange: () => void }) { + return ( + + ); +} + +function StatusLabel(props: { config: EmbeddingsConfig | null }) { + return ( + + Loading...}> + + Downloading model files... + + + Model ready + + + Download failed + + + Preparing model cache... + + + Semantic search is off + + + + ); +} + +function StatusLabelWithIcon(props: { config: EmbeddingsConfig | null }) { + return ( + + + + + Downloading model files... + + + + Model ready + + + + Download failed + + + + Preparing model cache... + + + + Semantic search is off + + + + ); +} + +type EmbeddingsSettingsProps = { onConfigChange?: (config: EmbeddingsConfig) => void }; + +export function EmbeddingsSettings(props: EmbeddingsSettingsProps) { + const [config, setConfig] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [autoPrepareStarted, setAutoPrepareStarted] = createSignal(false); + + async function loadConfig() { + try { + setLoading(true); + const nextConfig = await getEmbeddingsConfig(); + setConfig(nextConfig); + props.onConfigChange?.(nextConfig); + } catch (error) { + logger.error("failed to load embeddings config", { keyValues: { error: String(error) } }); + } finally { + setLoading(false); + } + } + + async function refreshConfig() { + try { + const nextConfig = await getEmbeddingsConfig(); + setConfig(nextConfig); + props.onConfigChange?.(nextConfig); + } catch (error) { + logger.error("failed to refresh embeddings config", { keyValues: { error: String(error) } }); + } + } + + async function prepareModel() { + try { + const nextConfig = await prepareEmbeddingsModel(); + setConfig(nextConfig); + props.onConfigChange?.(nextConfig); + } catch (error) { + logger.error("failed to prepare embeddings model", { keyValues: { error: String(error) } }); + await refreshConfig(); + } + } + + async function handleToggle() { + const current = config(); + if (!current) { + return; + } + + const nextEnabled = !current.enabled; + try { + await setEmbeddingsEnabled(nextEnabled); + if (!nextEnabled) { + setAutoPrepareStarted(false); + } + + await loadConfig(); + } catch (error) { + logger.error("failed to set embeddings enabled", { keyValues: { error: String(error) } }); + } + } + + const ofProgress = createMemo<[number, number] | null>(() => { + const index = config()?.downloadFileIndex; + const total = config()?.downloadFileTotal; + + if (typeof index === "number" && typeof total === "number" && total > 0) { + return [index, total]; + } + + return null; + }); + + createEffect(() => { + void loadConfig(); + }); + + createEffect(() => { + const current = config(); + if (!current) { + return; + } + + if (!current.enabled) { + setAutoPrepareStarted(false); + return; + } + + if (current.downloaded || current.downloadActive || autoPrepareStarted()) { + return; + } + + setAutoPrepareStarted(true); + void prepareModel(); + }); + + onMount(() => { + const interval = setInterval(() => { + if (config()?.downloadActive) { + void refreshConfig(); + } + }, 1000); + + onCleanup(() => clearInterval(interval)); + }); + + return ( +
+ + + + + +
+ + {formatProgress(config()?.downloadProgress)} +
+ +
+ +
+ +
+ + {(filename) =>

Current file: {filename().split("/").at(-1) ?? filename()}

} +
+ + + {(value) => { + const [index, total] = value(); + return

File {index} of {total}

; + }} +
+ + + {value =>

ETA: {formatEtaSeconds(value())}

} +
+ + {(message) =>

{message()}

}
+
+ + + + +
+
+
+ +

+ Semantic search can find conceptually similar posts even when they do not contain the exact keywords you typed. +

+
+ + +
+
+ ); +} diff --git a/src/components/search/SearchEmptyState.tsx b/src/components/search/SearchEmptyState.tsx index eb7159e..462969a 100644 --- a/src/components/search/SearchEmptyState.tsx +++ b/src/components/search/SearchEmptyState.tsx @@ -1,5 +1,5 @@ +import { Icon } from "$/components/shared/Icon"; import { Match, Show, Switch } from "solid-js"; -import { Icon } from "../shared/Icon"; type SearchEmptyStateProps = { reason: "initial" | "no-results" | "no-sync" }; @@ -14,7 +14,7 @@ export function SearchEmptyState(props: SearchEmptyStateProps) { function EmptyStateIcon(props: { reason: string }) { return ( -
+
}> @@ -45,9 +45,9 @@ function EmptyStateContent(props: { reason: string }) { function InitialContent() { return ( <> -

Search your saved posts

+

Search your saved & liked posts

- Type a query above to search through your liked and bookmarked posts. + Type a query above to search through the posts you liked or bookmarked.

@@ -56,13 +56,15 @@ function InitialContent() { function KeyboardShortcuts() { return ( -
-

- / Focus search from anywhere -

-

- Tab Cycle search modes -

+
+
+ / + Focus search from anywhere +
+
+ Tab + Cycle search modes +
); } @@ -82,7 +84,9 @@ function NoSyncContent() { return ( <>

No posts synced yet

-

Sync your liked and bookmarked posts to enable local search.

+

+ Sync your liked and bookmarked posts to build the local index for keyword and semantic search. +

); } diff --git a/src/components/search/SearchPanel.test.tsx b/src/components/search/SearchPanel.test.tsx index fb7365e..22c019e 100644 --- a/src/components/search/SearchPanel.test.tsx +++ b/src/components/search/SearchPanel.test.tsx @@ -6,10 +6,16 @@ const searchPostsMock = vi.hoisted(() => vi.fn()); const searchPostsNetworkMock = vi.hoisted(() => vi.fn()); const getSyncStatusMock = vi.hoisted(() => vi.fn()); const syncPostsMock = vi.hoisted(() => vi.fn()); +const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); +const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); +const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); vi.mock( "$/lib/api/search", () => ({ + getEmbeddingsConfig: getEmbeddingsConfigMock, + prepareEmbeddingsModel: prepareEmbeddingsModelMock, + setEmbeddingsEnabled: setEmbeddingsEnabledMock, searchPosts: searchPostsMock, searchPostsNetwork: searchPostsNetworkMock, getSyncStatus: getSyncStatusMock, @@ -26,20 +32,38 @@ describe("SearchPanel", () => { searchPostsNetworkMock.mockReset(); getSyncStatusMock.mockReset(); syncPostsMock.mockReset(); + getEmbeddingsConfigMock.mockReset(); + prepareEmbeddingsModelMock.mockReset(); + setEmbeddingsEnabledMock.mockReset(); getSyncStatusMock.mockResolvedValue([]); syncPostsMock.mockResolvedValue({ did: "did:plc:test", source: "like", - post_count: 100, - last_synced_at: "2026-03-29T12:00:00.000Z", + postCount: 100, + lastSyncedAt: "2026-03-29T12:00:00.000Z", }); + getEmbeddingsConfigMock.mockResolvedValue({ + enabled: true, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: true, + downloadActive: false, + }); + prepareEmbeddingsModelMock.mockResolvedValue({ + enabled: true, + modelName: "nomic-embed-text-v1.5", + dimensions: 768, + downloaded: true, + downloadActive: false, + }); + setEmbeddingsEnabledMock.mockResolvedValue(void 0); }); it("renders the search panel with initial state", async () => { render(() => ); - expect(await screen.findByPlaceholderText("Search posts...")).toBeInTheDocument(); + expect(await screen.findByPlaceholderText("Search your saved & liked posts...")).toBeInTheDocument(); expect(screen.getByText("Network")).toBeInTheDocument(); expect(screen.getByText("Keyword")).toBeInTheDocument(); expect(screen.getByText("Semantic")).toBeInTheDocument(); @@ -70,7 +94,7 @@ describe("SearchPanel", () => { render(() => ); - const input = await screen.findByPlaceholderText("Search posts..."); + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test query" } }); vi.advanceTimersByTime(350); @@ -79,42 +103,45 @@ describe("SearchPanel", () => { expect(searchPostsNetworkMock).toHaveBeenCalledWith("test query", "top", 25); }); - expect(await screen.findByText("Test post content")).toBeInTheDocument(); + expect(await screen.findByText(/post content/i)).toBeInTheDocument(); }); it("performs local search in keyword mode", async () => { + getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); searchPostsMock.mockResolvedValue([{ uri: "at://test", cid: "cid-1", - author_did: "did:plc:test", - author_handle: "test.bsky.social", + authorDid: "did:plc:test", + authorHandle: "test.bsky.social", text: "Local test post", - created_at: "2026-03-29T12:00:00.000Z", - indexed_at: "2026-03-29T12:00:00.000Z", + createdAt: "2026-03-29T12:00:00.000Z", source: "like" as const, + score: 1, + keywordMatch: true, + semanticMatch: false, }]); render(() => ); const keywordButton = screen.getByRole("button", { name: /keyword/i }); fireEvent.click(keywordButton); + expect(keywordButton).toHaveAttribute("aria-pressed", "true"); - const input = await screen.findByPlaceholderText("Search posts..."); + const input = screen.getByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test query" } }); - vi.advanceTimersByTime(350); - - await waitFor(() => { - expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); - }); + await vi.advanceTimersByTimeAsync(350); + await Promise.resolve(); + await Promise.resolve(); - expect(await screen.findByText("Local test post")).toBeInTheDocument(); + expect(searchPostsMock).toHaveBeenCalledWith("test query", "keyword", 50); + expect(screen.getByText("Liked")).toBeInTheDocument(); }); it("cycles through modes with Tab key", async () => { render(() => ); - const input = await screen.findByPlaceholderText("Search posts..."); + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); input.focus(); fireEvent.keyDown(input, { key: "Tab" }); @@ -134,7 +161,7 @@ describe("SearchPanel", () => { render(() => ); - const input = await screen.findByPlaceholderText("Search posts..."); + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test" } }); vi.advanceTimersByTime(350); @@ -152,7 +179,7 @@ describe("SearchPanel", () => { render(() => ); - const input = await screen.findByPlaceholderText("Search posts..."); + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "test" } }); vi.advanceTimersByTime(350); @@ -162,6 +189,7 @@ describe("SearchPanel", () => { }); it("shows empty state when no results found", async () => { + getSyncStatusMock.mockResolvedValue([{ did: "did:plc:test", source: "like", postCount: 12, lastSyncedAt: null }]); searchPostsMock.mockResolvedValue([]); render(() => ); @@ -169,7 +197,7 @@ describe("SearchPanel", () => { const keywordButton = screen.getByRole("button", { name: /keyword/i }); fireEvent.click(keywordButton); - const input = await screen.findByPlaceholderText("Search posts..."); + const input = await screen.findByPlaceholderText("Search your saved & liked posts..."); fireEvent.input(input, { target: { value: "nonexistent" } }); vi.advanceTimersByTime(350); diff --git a/src/components/search/SearchPanel.tsx b/src/components/search/SearchPanel.tsx index 67a656a..42ada02 100644 --- a/src/components/search/SearchPanel.tsx +++ b/src/components/search/SearchPanel.tsx @@ -1,16 +1,22 @@ import { Icon, SearchModeIcon } from "$/components/shared/Icon"; import { + type EmbeddingsConfig, + getEmbeddingsConfig, type LocalPostResult, type NetworkSearchResult, type SearchMode, searchPosts, searchPostsNetwork, + type SyncStatus, } from "$/lib/api/search"; +import { formatRelativeTime } from "$/lib/feeds"; import type { ActiveSession } from "$/lib/types"; import { normalizeError } from "$/lib/utils/text"; import * as logger from "@tauri-apps/plugin-log"; import { createEffect, createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"; import { Motion, Presence } from "solid-motionone"; +import { PostCount } from "../shared/PostCount"; +import { EmbeddingsSettings } from "./EmbeddingsSettings"; import { SearchEmptyState } from "./SearchEmptyState"; import { SearchResultCard } from "./SearchResultCard"; import { SyncStatusPanel } from "./SyncStatusPanel"; @@ -19,7 +25,7 @@ const MODES: SearchMode[] = ["network", "keyword", "semantic", "hybrid"]; function ModeLabel(props: { mode: SearchMode }) { return ( - + Network @@ -42,14 +48,43 @@ export function SearchPanel(props: SearchPanelProps) { const [error, setError] = createSignal(null); const [resultCount, setResultCount] = createSignal(0); const [hasSearched, setHasSearched] = createSignal(false); + const [syncStatus, setSyncStatus] = createSignal([]); + const [embeddingsConfig, setEmbeddingsConfig] = createSignal(null); let searchInputRef: HTMLInputElement | undefined; let debounceTimer: ReturnType | undefined; const isLocalMode = createMemo(() => mode() !== "network"); + const semanticEnabled = createMemo(() => embeddingsConfig()?.enabled ?? true); + const totalIndexedPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); + const hasLocalPosts = createMemo(() => totalIndexedPosts() > 0); + const lastSync = createMemo(() => { + const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; + if (timestamps.length === 0) { + return null; + } + + return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); + }); + const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); + + async function loadEmbeddingsConfig() { + try { + setEmbeddingsConfig(await getEmbeddingsConfig()); + } catch (err) { + logger.error("failed to load embeddings config", { keyValues: { error: normalizeError(err) } }); + } + } async function performSearch(searchQuery: string, searchMode: SearchMode) { if (!searchQuery.trim()) { + clearResults(); + return; + } + + if (searchMode === "semantic" && !semanticEnabled()) { + setError("Semantic search is disabled. Re-enable embeddings to use this mode."); + setHasSearched(true); setResults([]); setNetworkResults(null); setResultCount(0); @@ -63,22 +98,36 @@ export function SearchPanel(props: SearchPanelProps) { if (searchMode === "network") { const response = await searchPostsNetwork(searchQuery, "top", 25); setNetworkResults(response); + setResults([]); setResultCount(response.posts.length); } else { const response = await searchPosts(searchQuery, searchMode, 50); setResults(response); + setNetworkResults(null); setResultCount(response.length); } setHasSearched(true); } catch (err) { const errorMsg = normalizeError(err); setError(errorMsg); + setResults([]); + setNetworkResults(null); + setResultCount(0); + setHasSearched(true); logger.error("search failed", { keyValues: { query: searchQuery, mode: searchMode, error: errorMsg } }); } finally { setLoading(false); } } + function clearResults() { + setResults([]); + setNetworkResults(null); + setResultCount(0); + setError(null); + setHasSearched(false); + } + function handleInput(value: string) { setQuery(value); clearTimeout(debounceTimer); @@ -88,25 +137,28 @@ export function SearchPanel(props: SearchPanelProps) { } function handleModeChange(newMode: SearchMode) { + if (newMode === "semantic" && !semanticEnabled()) { + return; + } + setMode(newMode); if (query().trim()) { void performSearch(query(), newMode); + } else { + setError(null); } } function cycleMode() { - const currentIndex = MODES.indexOf(mode()); - const nextIndex = (currentIndex + 1) % MODES.length; - handleModeChange(MODES[nextIndex]); + const availableModes = cycleModes(); + const currentIndex = availableModes.indexOf(mode()); + const nextIndex = (currentIndex + 1) % availableModes.length; + handleModeChange(availableModes[nextIndex] ?? availableModes[0] ?? "network"); } function clearSearch() { setQuery(""); - setResults([]); - setNetworkResults(null); - setResultCount(0); - setHasSearched(false); - setError(null); + clearResults(); searchInputRef?.focus(); } @@ -120,7 +172,7 @@ export function SearchPanel(props: SearchPanelProps) { } function handleGlobalKeyDown(event: KeyboardEvent) { - if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key === "f")) { + if (event.key === "/" || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f")) { const target = event.target as HTMLElement; if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { event.preventDefault(); @@ -131,39 +183,62 @@ export function SearchPanel(props: SearchPanelProps) { onMount(() => { document.addEventListener("keydown", handleGlobalKeyDown); + void loadEmbeddingsConfig(); + onCleanup(() => { document.removeEventListener("keydown", handleGlobalKeyDown); clearTimeout(debounceTimer); }); }); + createEffect(() => { + if (mode() === "semantic" && !semanticEnabled()) { + setMode("keyword"); + } + }); + return ( -
- { - searchInputRef = el; - }} - onKeyDown={handleKeyDown} - onClear={clearSearch} /> - - - - -
+
+
+ { + searchInputRef = element; + }} + onKeyDown={handleKeyDown} + onClear={clearSearch} /> + + +
+ + +
); } @@ -172,6 +247,7 @@ function SearchHeader( error: string | null; hasSearched: boolean; inputRef: (el: HTMLInputElement) => void; + lastSync: string | null; loading: boolean; mode: SearchMode; onClear: () => void; @@ -180,10 +256,12 @@ function SearchHeader( onQueryChange: (value: string) => void; query: string; resultCount: number; + semanticEnabled: boolean; + totalIndexedPosts: number; }, ) { return ( -
+
-
- +
+ Tab to switch modes
- - - +
); } -function ResultCount(props: { count: number }) { +function ResultMeta( + props: { hasSearched: boolean; lastSync: string | null; resultCount: number; totalIndexedPosts: number }, +) { return ( -
+
- Found {props.count} results + + + Found {props.resultCount} results + + + + + + 0}> + +
); @@ -229,24 +326,34 @@ function SearchInput( }, ) { return ( -
-
- -
+
+
+
+ +
- props.onQueryChange(e.currentTarget.value)} - onKeyDown={(e) => props.onKeyDown(e)} /> - -
- - + props.onQueryChange(event.currentTarget.value)} + onKeyDown={(event) => props.onKeyDown(event)} /> + +
+ + +
+ + + {(message) => ( +
+ {message()} +
+ )} +
); } @@ -275,19 +382,24 @@ function ClearButton(props: { query: string; loading: boolean; onClear: () => vo ); } -function ModeSelector(props: { activeMode: SearchMode; onModeChange: (mode: SearchMode) => void }) { +function ModeSelector( + props: { activeMode: SearchMode; semanticEnabled: boolean; onModeChange: (mode: SearchMode) => void }, +) { const [indicatorStyle, setIndicatorStyle] = createSignal({ left: "0px", width: "0px" }); const [containerRef, setContainerRef] = createSignal(); createEffect(() => { - const mode = props.activeMode; const ref = containerRef(); - if (!ref) return; + if (!ref) { + return; + } const buttons = ref.querySelectorAll("button"); - const activeIndex = MODES.indexOf(mode); + const activeIndex = MODES.indexOf(props.activeMode); const activeButton = buttons[activeIndex]; - if (!activeButton) return; + if (!activeButton) { + return; + } const rect = activeButton.getBoundingClientRect(); const containerRect = ref.getBoundingClientRect(); @@ -296,26 +408,32 @@ function ModeSelector(props: { activeMode: SearchMode; onModeChange: (mode: Sear }); return ( -
+
- {(searchMode) => ( - - )} + {(searchMode) => { + const disabled = searchMode === "semantic" && !props.semanticEnabled; + return ( + + ); + }}
); @@ -323,10 +441,13 @@ function ModeSelector(props: { activeMode: SearchMode; onModeChange: (mode: Sear function SearchViewport( props: { + error: string | null; + hasLocalPosts: boolean; hasSearched: boolean; isLocalMode: boolean; loading: boolean; localResults: LocalPostResult[]; + mode: SearchMode; networkResults: NetworkSearchResult | null; query: string; }, @@ -344,10 +465,13 @@ function SearchViewport( function SearchState( props: { + error: string | null; + hasLocalPosts: boolean; hasSearched: boolean; isLocalMode: boolean; loading: boolean; localResults: LocalPostResult[]; + mode: SearchMode; networkResults: NetworkSearchResult | null; query: string; }, @@ -355,6 +479,14 @@ function SearchState( return ( + + + + + + + + @@ -368,18 +500,18 @@ function SearchState( - + - + ); } -function EmptyStateView(props: { reason: "initial" | "no-results" }) { +function EmptyStateView(props: { reason: "initial" | "no-results" | "no-sync" }) { return ( + text={result.text ?? ""} + createdAt={result.createdAt ?? ""} + isSemanticMatch={result.semanticMatch && !result.keywordMatch} + query={props.query} /> )} @@ -422,7 +555,7 @@ function LocalResultsList(props: { results: LocalPostResult[] }) { ); } -function NetworkResultsList(props: { results: NetworkSearchResult | null }) { +function NetworkResultsList(props: { query: string; results: NetworkSearchResult | null }) { return ( + query={props.query} /> )} @@ -466,3 +599,31 @@ function SearchSkeleton() {
); } + +function SearchTipsCard() { + return ( +
+

Search Tips

+
+

+ / + Focus search from anywhere +

+

+ Tab + Cycle search modes +

+
+
+
·
+
Use keyword mode for exact terms and hybrid mode for broader recall.
+
+
+
·
+
Network search queries public Bluesky posts without using your local index.
+
+
+
+
+ ); +} diff --git a/src/components/search/SearchResultCard.tsx b/src/components/search/SearchResultCard.tsx index 9c74eef..f10009b 100644 --- a/src/components/search/SearchResultCard.tsx +++ b/src/components/search/SearchResultCard.tsx @@ -3,67 +3,6 @@ import { escapeForRegex } from "$/lib/utils/text"; import { createMemo, type JSX, Show } from "solid-js"; import { Icon } from "../shared/Icon"; -type SearchResultCardProps = { - authorHandle: string; - source: "like" | "bookmark" | "network"; - text: string; - createdAt: string; - likeCount?: number; - replyCount?: number; - isSemanticMatch?: boolean; - query?: string; -}; - -export function SearchResultCard(props: SearchResultCardProps) { - const avatarLabel = createMemo(() => props.authorHandle.slice(0, 1).toUpperCase() || "?"); - - const formattedTime = createMemo(() => formatRelativeTime(props.createdAt)); - - const sourceLabel = createMemo(() => { - switch (props.source) { - case "like": { - return "Liked"; - } - case "bookmark": { - return "Bookmarked"; - } - default: { - return null; - } - } - }); - - const highlightedText = createMemo(() => { - if (!props.query || !props.text) { - return props.text; - } - - const parts = props.text.split(new RegExp(`(${escapeForRegex(props.query)})`, "gi")); - return parts.map((part) => { - if (part.toLowerCase() === props.query?.toLowerCase()) { - return {part}; - } - return part; - }); - }); - - return ( -
- -
- ); -} - function CardContent( props: { avatarLabel: string; @@ -156,3 +95,70 @@ function StatBadge(props: { kind: "like" | "reply"; value?: number; label: strin ); } + +type SearchResultCardProps = { + authorHandle: string; + source: "like" | "bookmark" | "network"; + text: string; + createdAt: string; + likeCount?: number; + replyCount?: number; + isSemanticMatch?: boolean; + query?: string; +}; + +export function SearchResultCard(props: SearchResultCardProps) { + const avatarLabel = createMemo(() => props.authorHandle.slice(0, 1).toUpperCase() || "?"); + const formattedTime = createMemo(() => (props.createdAt ? formatRelativeTime(props.createdAt) : "Unknown date")); + + const sourceLabel = createMemo(() => { + switch (props.source) { + case "like": { + return "Liked"; + } + case "bookmark": { + return "Bookmarked"; + } + default: { + return null; + } + } + }); + + const highlightedText = createMemo(() => { + if (!props.query || !props.text) { + return props.text; + } + + const tokens = [...new Set(props.query.split(/\s+/).map((token) => token.trim()).filter(Boolean))]; + if (tokens.length === 0) { + return props.text; + } + + const pattern = tokens.toSorted((left, right) => right.length - left.length).map((token) => escapeForRegex(token)) + .join("|"); + const parts = props.text.split(new RegExp(`(${pattern})`, "gi")); + return parts.map((part) => { + if (tokens.some((token) => token.toLowerCase() === part.toLowerCase())) { + return {part}; + } + return part; + }); + }); + + return ( +
+ +
+ ); +} diff --git a/src/components/search/SyncStatusPanel.test.tsx b/src/components/search/SyncStatusPanel.test.tsx new file mode 100644 index 0000000..22f44bb --- /dev/null +++ b/src/components/search/SyncStatusPanel.test.tsx @@ -0,0 +1,214 @@ +import { fireEvent, render, screen, waitFor } from "@solidjs/testing-library"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SyncStatusPanel } from "./SyncStatusPanel"; + +const getSyncStatusMock = vi.hoisted(() => vi.fn()); +const syncPostsMock = vi.hoisted(() => vi.fn()); +const reindexEmbeddingsMock = vi.hoisted(() => vi.fn()); + +vi.mock( + "$/lib/api/search", + () => ({ getSyncStatus: getSyncStatusMock, syncPosts: syncPostsMock, reindexEmbeddings: reindexEmbeddingsMock }), +); + +vi.mock("@tauri-apps/plugin-log", () => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn() })); + +describe("SyncStatusPanel", () => { + beforeEach(() => { + vi.useFakeTimers(); + getSyncStatusMock.mockReset(); + syncPostsMock.mockReset(); + reindexEmbeddingsMock.mockReset(); + + getSyncStatusMock.mockResolvedValue([{ + did: "did:plc:test", + source: "like", + postCount: 100, + lastSyncedAt: "2026-03-29T12:00:00.000Z", + cursor: "cursor-123", + }, { + did: "did:plc:test", + source: "bookmark", + postCount: 50, + lastSyncedAt: "2026-03-29T11:00:00.000Z", + cursor: null, + }]); + + syncPostsMock.mockResolvedValue({ + did: "did:plc:test", + source: "like", + postCount: 150, + lastSyncedAt: "2026-03-29T13:00:00.000Z", + }); + + reindexEmbeddingsMock.mockResolvedValue(150); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders sync status with post counts", async () => { + render(() => ); + + expect(await screen.findByText("Ready")).toBeInTheDocument(); + expect(await screen.findByText(/150/)).toBeInTheDocument(); + expect(await screen.findByText(/posts indexed/i)).toBeInTheDocument(); + }); + + it("shows last sync time", async () => { + render(() => ); + + expect(await screen.findByText(/last sync/i)).toBeInTheDocument(); + }); + + it("triggers sync when clicking sync button", async () => { + render(() => ); + + const syncButton = await screen.findByRole("button", { name: /sync now/i }); + fireEvent.click(syncButton); + + await waitFor(() => { + expect(syncPostsMock).toHaveBeenCalledWith("did:plc:test", "like"); + expect(syncPostsMock).toHaveBeenCalledWith("did:plc:test", "bookmark"); + }); + }); + + it("shows syncing state during sync", async () => { + syncPostsMock.mockImplementation(() => + new Promise((resolve) => + setTimeout( + () => + resolve({ did: "did:plc:test", source: "like", postCount: 150, lastSyncedAt: "2026-03-29T13:00:00.000Z" }), + 100, + ) + ) + ); + + render(() => ); + + const syncButton = await screen.findByRole("button", { name: /sync now/i }); + fireEvent.click(syncButton); + + // Check that the button shows syncing state and is disabled + await waitFor(() => { + expect(screen.getByRole("button", { name: /syncing/i })).toBeDisabled(); + }); + }, 5000); + + it("shows reindex button when posts exist", async () => { + render(() => ); + + const reindexButton = await screen.findByRole("button", { name: /reindex/i }); + expect(reindexButton).toBeInTheDocument(); + }); + + it("triggers reindex when clicking reindex button", async () => { + render(() => ); + + const reindexButton = await screen.findByRole("button", { name: /reindex/i }); + fireEvent.click(reindexButton); + + await waitFor(() => { + expect(reindexEmbeddingsMock).toHaveBeenCalled(); + }); + }); + + it("shows reindexing state during reindex", async () => { + reindexEmbeddingsMock.mockImplementation(() => new Promise(() => {})); // Never resolves + + render(() => ); + + const reindexButton = await screen.findByRole("button", { name: /reindex/i }); + fireEvent.click(reindexButton); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /reindexing/i })).toBeInTheDocument(); + }); + }); + + it("shows progress bars during operations", async () => { + syncPostsMock.mockImplementation(() => new Promise(() => {})); // Never resolves + + const { container } = render(() => ); + + const syncButton = await screen.findByRole("button", { name: /sync now/i }); + fireEvent.click(syncButton); + + // Progress bar should be visible (it's a div with gradient) + await waitFor(() => { + const progressBars = container.querySelectorAll("[class*=\"bg-linear-to-r\"]"); + expect(progressBars.length).toBeGreaterThan(0); + }); + }); + + it("shows source-specific progress bars", async () => { + render(() => ); + + expect(await screen.findByText(/liked posts/i)).toBeInTheDocument(); + expect(await screen.findByText(/bookmarked posts/i)).toBeInTheDocument(); + }); + + it("hides reindex button when no posts exist", async () => { + getSyncStatusMock.mockResolvedValue([{ + did: "did:plc:test", + source: "like", + postCount: 0, + lastSyncedAt: null, + cursor: null, + }]); + + render(() => ); + + await waitFor(() => { + const reindexButton = screen.queryByRole("button", { name: /reindex/i }); + expect(reindexButton).not.toBeInTheDocument(); + }); + }); + + it("polls for sync status updates", async () => { + render(() => ); + + await waitFor(() => { + expect(getSyncStatusMock).toHaveBeenCalledTimes(1); + }); + + vi.advanceTimersByTime(60_000); + + await waitFor(() => { + expect(getSyncStatusMock).toHaveBeenCalledTimes(2); + }); + }); + + it("handles sync errors gracefully", async () => { + syncPostsMock.mockRejectedValue(new Error("Sync failed")); + + render(() => ); + + const syncButton = await screen.findByRole("button", { name: /sync now/i }); + fireEvent.click(syncButton); + + await waitFor(() => { + expect(syncPostsMock).toHaveBeenCalled(); + }); + + // Should return to normal state after error + expect(await screen.findByRole("button", { name: /sync now/i })).toBeEnabled(); + }); + + it("disables buttons during operations", async () => { + syncPostsMock.mockImplementation(() => new Promise(() => {})); + + render(() => ); + + const syncButton = await screen.findByRole("button", { name: /sync now/i }); + const reindexButton = screen.getByRole("button", { name: /reindex/i }); + + fireEvent.click(syncButton); + + await waitFor(() => { + expect(syncButton).toBeDisabled(); + expect(reindexButton).toBeDisabled(); + }); + }); +}); diff --git a/src/components/search/SyncStatusPanel.tsx b/src/components/search/SyncStatusPanel.tsx index 6028f94..69183ec 100644 --- a/src/components/search/SyncStatusPanel.tsx +++ b/src/components/search/SyncStatusPanel.tsx @@ -1,18 +1,79 @@ -import { getSyncStatus, syncPosts, type SyncStatus } from "$/lib/api/search"; +import { Icon } from "$/components/shared/Icon"; +import { getSyncStatus, reindexEmbeddings, syncPosts, type SyncStatus } from "$/lib/api/search"; +import { formatRelativeTime } from "$/lib/feeds"; import * as logger from "@tauri-apps/plugin-log"; -import { createSignal, onMount, Show } from "solid-js"; +import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import { Motion } from "solid-motionone"; +import { PostCount } from "../shared/PostCount"; -type SyncStatusPanelProps = { did: string }; +function SourceStatusRow( + props: { count: number; cursor?: string | null; isActive: boolean; source: "like" | "bookmark" }, +) { + const label = createMemo(() => (props.source === "like" ? "Liked posts" : "Bookmarked posts")); + + return ( +
+
+ {label()} + {props.count} synced +
+ +
+
0 ? "100%" : "0%" }} /> +
+ + +

Resume cursor saved for interrupted sync recovery.

+
+
+ ); +} + +function ReindexButton(props: { isSyncing: boolean; isReindexing: boolean; onReindex: () => void }) { + return ( + + ); +} + +function SyncButton(props: { isSyncing: boolean; isReindexing: boolean; onSync: () => void }) { + return ( + + ); +} + +type SyncStatusPanelProps = { did: string; onStatusChange?: (status: SyncStatus[]) => void }; export function SyncStatusPanel(props: SyncStatusPanelProps) { const [syncStatus, setSyncStatus] = createSignal([]); const [isSyncing, setIsSyncing] = createSignal(false); + const [isReindexing, setIsReindexing] = createSignal(false); async function loadSyncStatus() { try { const status = await getSyncStatus(props.did); setSyncStatus(status); + props.onStatusChange?.(status); } catch (error) { logger.error("failed to load sync status", { keyValues: { error: String(error) } }); } @@ -31,6 +92,19 @@ export function SyncStatusPanel(props: SyncStatusPanelProps) { } } + async function handleReindex() { + setIsReindexing(true); + try { + const count = await reindexEmbeddings(); + logger.info("reindex complete", { keyValues: { count: String(count) } }); + await loadSyncStatus(); + } catch (error) { + logger.error("reindex failed", { keyValues: { error: String(error) } }); + } finally { + setIsReindexing(false); + } + } + onMount(() => { void loadSyncStatus(); @@ -38,98 +112,83 @@ export function SyncStatusPanel(props: SyncStatusPanelProps) { void loadSyncStatus(); }, 60_000); - return () => clearInterval(interval); + onCleanup(() => clearInterval(interval)); }); - const totalPosts = () => syncStatus().reduce((sum, s) => sum + (s.post_count ?? 0), 0); + const totalPosts = createMemo(() => syncStatus().reduce((sum, status) => sum + (status.postCount ?? 0), 0)); + const hasAnyPosts = createMemo(() => totalPosts() > 0); + const lastSync = createMemo(() => { + const timestamps = syncStatus().map((status) => status.lastSyncedAt).filter(Boolean) as string[]; + if (timestamps.length === 0) { + return null; + } - const lastSyncTime = () => { - const times = syncStatus().map((s) => s.last_synced_at).filter(Boolean) as string[]; - if (times.length === 0) return null; - const latest = times.toSorted().toReversed()[0]; + const latest = timestamps.toSorted((left, right) => right.localeCompare(left))[0]; return formatRelativeTime(latest); - }; + }); + const statusTone = createMemo(() => { + if (isSyncing() || isReindexing()) { + return { className: "bg-primary/15 text-primary", label: isReindexing() ? "Reindexing" : "Syncing" }; + } - return ( - -
- - -
-
- ); -} + if (hasAnyPosts()) { + return { className: "bg-emerald-400/15 text-emerald-300", label: "Ready" }; + } + + return { className: "bg-white/8 text-on-surface-variant", label: "Empty" }; + }); -function StatusInfo(props: { isSyncing: boolean; totalPosts: number; lastSync: string | null }) { return ( -
-
- - {props.isSyncing ? "Syncing..." : "Active"} +
+
+
+
+

Sync Status

+ + {statusTone().label} + +
+ + + Local search is empty until likes or bookmarks are indexed. +

+ }> + +
+
+ +
+ + + + + +
- 0}> - - {props.totalPosts} posts indexed - - - - - {(time) => · Last sync: {time()}} + +
+ +
-
- ); -} -function StatusIndicator(props: { isSyncing: boolean }) { - return ( - }> - - - ); -} - -function SyncButton(props: { isSyncing: boolean; onSync: () => void }) { - return ( - +
+ + {(status) => ( + + )} + +
+ ); } - -function formatRelativeTime(value: string) { - const timestamp = new Date(value).getTime(); - if (Number.isNaN(timestamp)) { - return ""; - } - - const deltaSeconds = Math.round((timestamp - Date.now()) / 1000); - const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); - const ranges = [ - ["year", 60 * 60 * 24 * 365], - ["month", 60 * 60 * 24 * 30], - ["day", 60 * 60 * 24], - ["hour", 60 * 60], - ["minute", 60], - ] as const; - - for (const [unit, seconds] of ranges) { - if (Math.abs(deltaSeconds) >= seconds) { - return formatter.format(Math.round(deltaSeconds / seconds), unit); - } - } - - return formatter.format(deltaSeconds, "second"); -} diff --git a/src/components/shared/Icon.tsx b/src/components/shared/Icon.tsx index ace4d2d..b6abe86 100644 --- a/src/components/shared/Icon.tsx +++ b/src/components/shared/Icon.tsx @@ -30,7 +30,8 @@ export type IconKind = | "danger" | "repost" | "reply" - | "follow"; + | "follow" + | "download"; type IconProps = JSX.HTMLAttributes & { class?: string; @@ -129,6 +130,9 @@ export function Icon(props: IconProps) { + + + ); diff --git a/src/components/shared/PostCount.tsx b/src/components/shared/PostCount.tsx new file mode 100644 index 0000000..82388c5 --- /dev/null +++ b/src/components/shared/PostCount.tsx @@ -0,0 +1,45 @@ +import { Show } from "solid-js"; + +export function PostCount(props: { totalPosts: number; lastSync?: string | null; inline?: boolean }) { + return ( + }> + + + ); +} + +function InlinePostCount(props: { totalPosts: number; lastSync?: string | null }) { + return ( + + {props.totalPosts} + Indexed + + {(value) => ( + <> + · + Sync {value()} + + )} + + + ); +} + +function BlockPostCount(props: { totalPosts: number; lastSync?: string | null }) { + return ( +

+ + {props.totalPosts} + posts indexed + + {(value) => ( + <> + · + Last sync {value()} + + )} + + +

+ ); +} diff --git a/src/lib/api/search.ts b/src/lib/api/search.ts index 2ea3473..7eebfa7 100644 --- a/src/lib/api/search.ts +++ b/src/lib/api/search.ts @@ -10,43 +10,51 @@ export type ActorSearchResult = { actors: { did: string; handle: string; displayName?: string | null; avatar?: string | null }[]; }; -export type StarterPackSearchResult = { - cursor?: string | null; - starterPacks: { - uri: string; - cid: string; - record: { name: string; description?: string; createdAt: string }; - creator: { did: string; handle: string; displayName?: string | null; avatar?: string | null }; - indexedAt: string; - }[]; +type TStarterPack = { + uri: string; + cid: string; + record: { name: string; description?: string; createdAt: string }; + creator: { did: string; handle: string; displayName?: string | null; avatar?: string | null }; + indexedAt: string; }; +export type StarterPackSearchResult = { cursor?: string | null; starterPacks: Array }; + +type TPostSource = "like" | "bookmark"; + export type LocalPostResult = { uri: string; cid: string; - author_did: string; - author_handle: string; - text: string; - created_at: string; - indexed_at: string; - source: "like" | "bookmark"; - score?: number; + authorDid: string; + authorHandle?: string | null; + text?: string | null; + createdAt?: string | null; + source: TPostSource; + score: number; + keywordMatch: boolean; + semanticMatch: boolean; }; export type SyncStatus = { did: string; - source: "like" | "bookmark"; + source: TPostSource; cursor?: string | null; - last_synced_at?: string | null; - post_count?: number; + lastSyncedAt?: string | null; + postCount?: number; }; export type EmbeddingsConfig = { enabled: boolean; - model_name: string; + modelName: string; dimensions: number; downloaded: boolean; - download_progress?: number; + downloadActive: boolean; + downloadProgress?: number | null; + downloadEtaSeconds?: number | null; + downloadFile?: string | null; + downloadFileIndex?: number | null; + downloadFileTotal?: number | null; + lastError?: string | null; }; export function searchPostsNetwork( @@ -93,3 +101,15 @@ export function reindexEmbeddings(): Promise { export function setEmbeddingsEnabled(enabled: boolean): Promise { return invoke("set_embeddings_enabled", { enabled }); } + +export function getEmbeddingsEnabled(): Promise { + return invoke("get_embeddings_enabled"); +} + +export function getEmbeddingsConfig(): Promise { + return invoke("get_embeddings_config"); +} + +export function prepareEmbeddingsModel(): Promise { + return invoke("prepare_embeddings_model"); +} diff --git a/src/lib/utils/text.ts b/src/lib/utils/text.ts index d3da75b..8713da6 100644 --- a/src/lib/utils/text.ts +++ b/src/lib/utils/text.ts @@ -23,3 +23,21 @@ export function normalizeError(err: unknown): string { return String(err); } } + +export function formatEtaSeconds(value: number) { + if (value < 60) { + return `${value}s`; + } + + const minutes = Math.floor(value / 60); + const seconds = value % 60; + return `${minutes}m ${seconds}s`; +} + +export function formatProgress(value: number | null | undefined) { + if (typeof value !== "number" || Number.isNaN(value)) { + return "Pending"; + } + + return `${Math.round(value)}%`; +} diff --git a/src/router.test.tsx b/src/router.test.tsx index 6d69aba..9fd93b9 100644 --- a/src/router.test.tsx +++ b/src/router.test.tsx @@ -17,10 +17,15 @@ function renderRouter(hash: string) { const renderNotifications = vi.fn((currentSession: ActiveSession) => (
{currentSession.handle}
)); - const renderTimeline = vi.fn((currentSession: ActiveSession, context: { threadUri: string | null }) => ( + const renderTimeline = vi.fn(( + props: { + session: ActiveSession; + context: { onThreadRouteChange: (uri: string | null) => void; threadUri: string | null }; + }, + ) => (
- {currentSession.handle} - {context.threadUri ?? "no-thread"} + {props.session.handle} + {props.context.threadUri ?? "no-thread"}
)); @@ -46,7 +51,7 @@ describe("AppRouter", () => { await screen.findByTestId("timeline-view"); expect(renderTimeline).toHaveBeenCalled(); - expect(renderTimeline.mock.lastCall?.[1].threadUri).toBeNull(); + expect(renderTimeline.mock.lastCall?.[0].context.threadUri).toBeNull(); expect(screen.getByText("no-thread")).toBeInTheDocument(); }); @@ -56,7 +61,7 @@ describe("AppRouter", () => { await screen.findByTestId("timeline-view"); - expect(renderTimeline.mock.lastCall?.[1].threadUri).toBe(threadUri); + expect(renderTimeline.mock.lastCall?.[0].context.threadUri).toBe(threadUri); expect(screen.getByText(threadUri)).toBeInTheDocument(); });