diff --git a/docs/specs/settings.md b/docs/specs/settings.md index 445a6cb..82ebdea 100644 --- a/docs/specs/settings.md +++ b/docs/specs/settings.md @@ -17,19 +17,19 @@ All settings are stored as text values. The backend exposes typed accessors that ### Settings Keys -| Key | Type | Default | Description | -| ----------------------- | ------- | ------------------------------------------ | ------------------------------------------ | -| `theme` | string | `"auto"` | `"light"`, `"dark"`, or `"auto"` (OS sync) | -| `timeline_refresh_secs` | integer | `60` | Feed auto-refresh interval in seconds | -| `notifications_desktop` | boolean | `true` | Show OS desktop notifications | -| `notifications_badge` | boolean | `true` | Show unread badge on app icon / tray | -| `notifications_sound` | boolean | `false` | Play sound on new notification | -| `embeddings_enabled` | boolean | `false` | Enable optional semantic search | -| `constellation_url` | string | `"https://constellation.microcosm.blue"` | Constellation instance base URL | -| `spacedust_url` | string | `"https://spacedust.microcosm.blue"` | Spacedust instance base URL | -| `spacedust_instant` | boolean | `false` | Bypass Spacedust 21-second debounce buffer | -| `spacedust_enabled` | boolean | `false` | Use Spacedust for real-time notifications | -| `global_shortcut` | string | `"Ctrl+Shift+N"` | Global composer shortcut | +| Key | Type | Default | Description | +| ----------------------- | ------- | ---------------------------------------- | ------------------------------------------ | +| `theme` | string | `"auto"` | `"light"`, `"dark"`, or `"auto"` (OS sync) | +| `timeline_refresh_secs` | integer | `60` | Feed auto-refresh interval in seconds | +| `notifications_desktop` | boolean | `true` | Show OS desktop notifications | +| `notifications_badge` | boolean | `true` | Show unread badge on app icon / tray | +| `notifications_sound` | boolean | `false` | Play sound on new notification | +| `embeddings_enabled` | boolean | `false` | Enable optional semantic search | +| `constellation_url` | string | `"https://constellation.microcosm.blue"` | Constellation instance base URL | +| `spacedust_url` | string | `"https://spacedust.microcosm.blue"` | Spacedust instance base URL | +| `spacedust_instant` | boolean | `false` | Bypass Spacedust 21-second debounce buffer | +| `spacedust_enabled` | boolean | `false` | Use Spacedust for real-time notifications | +| `global_shortcut` | string | `"Ctrl+Shift+N"` | Global composer shortcut | ## Tauri Commands @@ -105,9 +105,12 @@ External service instance configuration for self-hosters. - **Cache size display**: breakdown by category (feeds, embeddings, FTS index) with total - **Clear cache**: scoped clearing — all, or by category. Confirmation dialog for "clear all" - **Export**: export user data (liked posts, bookmarks, settings) as JSON or CSV. Uses Tauri's save dialog to pick destination. -- **Reset app**: full data wipe — drops all user tables, clears auth tokens, re-runs migrations. Behind a two-step confirmation: type "RESET" to confirm. This is the nuclear option. -### 8. Logs +### 8. Danger Zone + +- **Reset and restart**: full data wipe — drops all user tables, clears auth tokens, removes the downloaded embeddings model cache, re-runs migrations, and restarts the app. Behind a two-step confirmation: type "RESET" to confirm. This is the nuclear option. + +### 9. Logs In-app log viewer for debugging. @@ -117,7 +120,7 @@ In-app log viewer for debugging. - "Copy all" and "Open log file" actions - Collapsible by default — expands inline within the settings view -### 9. About +### 10. About - App version (from `tauri.conf.json`) - License (MIT) @@ -133,10 +136,10 @@ On narrow viewports, cards stack full-width. On wider viewports (> 768px), cards ## Keyboard Shortcuts -| Key | Action | -| ------- | ----------------------------- | -| `,` | Open/focus settings from anywhere | -| `Escape`| Close settings (navigate back) | +| Key | Action | +| -------- | --------------------------------- | +| `,` | Open/focus settings from anywhere | +| `Escape` | Close settings (navigate back) | ## UX Polish diff --git a/index.html b/index.html index 9c2276a..e4523a4 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,16 @@ + Tauri + Solid + Typescript App diff --git a/src-tauri/src/search.rs b/src-tauri/src/search.rs index 3e2b716..d779c26 100644 --- a/src-tauri/src/search.rs +++ b/src-tauri/src/search.rs @@ -786,6 +786,15 @@ fn resolve_models_dir(app: &AppHandle) -> Result { Ok(dir) } +fn models_dir_path(app: &AppHandle) -> Result { + let mut dir = app + .path() + .app_data_dir() + .map_err(|error| AppError::PathResolve(error.to_string()))?; + dir.push("models"); + Ok(dir) +} + fn required_embedding_files() -> Vec<&'static str> { let mut files = vec![EMBEDDING_MODEL_FILE]; files.extend(EMBEDDING_TOKENIZER_FILES); @@ -847,6 +856,14 @@ fn set_download_error(message: String) { } } +fn clear_embeddings_model_cache_dir(models_dir: &Path) -> Result<()> { + if models_dir.exists() { + fs::remove_dir_all(models_dir)?; + } + set_download_idle_state(0, required_embedding_files().len()); + Ok(()) +} + fn ensure_model_downloaded(models_dir: &Path) -> Result<()> { let required_files = required_embedding_files(); let total_files = required_files.len(); @@ -1360,6 +1377,11 @@ pub fn prepare_embeddings_model(app: &AppHandle, state: &AppState) -> Result Result<()> { + let models_dir = models_dir_path(app)?; + clear_embeddings_model_cache_dir(&models_dir) +} + fn sync_due(active_did: Option<&str>, last_synced_did: Option<&str>, last_synced_at: Option) -> bool { match active_did { None => false, @@ -1427,14 +1449,17 @@ pub fn spawn_search_sync_task(app: AppHandle) { #[cfg(test)] mod tests { use super::{ - build_fts_match_query, build_search_posts_request, db_get_embeddings_enabled, db_get_embeddings_preflight_seen, - db_list_saved_posts, db_load_sync_cursor, db_post_count, db_save_sync_state, db_semantic_search, - db_set_embeddings_enabled, db_set_embeddings_preflight_seen, db_sync_status, db_upsert_embedding, - db_upsert_post, normalize_identifier_filter, normalize_tag_filter, run_local_search, storage_key, sync_due, - validate_limit, validate_query, validate_search_mode, validate_source, NetworkSearchQueryParams, SearchMode, + build_fts_match_query, build_search_posts_request, clear_embeddings_model_cache_dir, db_get_embeddings_enabled, + db_get_embeddings_preflight_seen, db_list_saved_posts, db_load_sync_cursor, db_post_count, db_save_sync_state, + db_semantic_search, db_set_embeddings_enabled, db_set_embeddings_preflight_seen, db_sync_status, + db_upsert_embedding, db_upsert_post, normalize_identifier_filter, normalize_tag_filter, run_local_search, + storage_key, sync_due, validate_limit, validate_query, validate_search_mode, validate_source, + NetworkSearchQueryParams, SearchMode, }; use rusqlite::{ffi::sqlite3_auto_extension, Connection}; use sqlite_vec::sqlite3_vec_init; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; fn test_db() -> Connection { unsafe { @@ -1505,6 +1530,27 @@ mod tests { }) } + fn temp_models_dir() -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("lazurite-model-cache-{unique}")) + } + + #[test] + fn clear_embeddings_model_cache_dir_removes_cached_files() { + let models_dir = temp_models_dir(); + let nested_dir = models_dir.join("nested"); + fs::create_dir_all(&nested_dir).expect("nested models dir should be created"); + fs::write(models_dir.join("model.onnx"), "model").expect("model file should be created"); + fs::write(nested_dir.join("tokenizer.json"), "tokenizer").expect("tokenizer file should be created"); + + clear_embeddings_model_cache_dir(&models_dir).expect("model cache should clear"); + + assert!(!models_dir.exists(), "models dir should be removed after clearing"); + } + fn insert_post(conn: &Connection, owner_did: &str, uri: &str, source: &str, text: &str, created_at: &str) { let item = feed_item(uri, "cid", "did:plc:author", "author.test", text, created_at); db_upsert_post(conn, owner_did, &item, source).expect("post should insert"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 38d93e4..ca8d4d6 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -2,6 +2,7 @@ use super::auth; use super::db; use super::error::{AppError, Result}; use super::notifications; +use super::search; use super::state::AppState; use super::tray; use reqwest::Url; @@ -618,6 +619,7 @@ pub fn reset_app(state: &AppState, app: &AppHandle) -> Result<()> { db_reset_app(&conn)?; drop(conn); + search::clear_embeddings_model_cache(app)?; state.clear_runtime_state()?; notifications::clear_unread_badge(app); tray::sync_global_shortcut(app)?; diff --git a/src/components/search/EmbeddingsSettings.test.tsx b/src/components/search/EmbeddingsSettings.test.tsx index d9060c8..169ec05 100644 --- a/src/components/search/EmbeddingsSettings.test.tsx +++ b/src/components/search/EmbeddingsSettings.test.tsx @@ -78,9 +78,9 @@ describe("EmbeddingsSettings", () => { renderEmbeddingsSettings(); expect(await screen.findByText("Optional Semantic Search")).toBeInTheDocument(); - expect(await screen.findAllByText(/nomic-embed-text-v1\.5/)).toHaveLength(2); - expect(await screen.findAllByText(/768D/)).toHaveLength(2); - expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(2); + expect(await screen.findAllByText(/nomic-embed-text-v1\.5/)).toHaveLength(1); + expect(await screen.findAllByText(/768D/)).toHaveLength(1); + expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(1); expect(await screen.findAllByText(/off by default/i)).toHaveLength(2); }); diff --git a/src/components/search/EmbeddingsSettings.tsx b/src/components/search/EmbeddingsSettings.tsx index bcc1a7d..6f6b43a 100644 --- a/src/components/search/EmbeddingsSettings.tsx +++ b/src/components/search/EmbeddingsSettings.tsx @@ -23,28 +23,31 @@ function ModelDescriptor(props: { config: EmbeddingsConfig | null }) { function EmbedSettingsHeader(props: { config: EmbeddingsConfig | null; isLoading: boolean; handleToggle: () => void }) { return (
-
- +
+
+ +
-

Optional Semantic Search

+

Optional Semantic Search

Off by default. Turn this on to download a local model and unlock semantic plus hybrid search for synced posts.

- - - {(current) => ( - void props.handleToggle()} /> - )} - +
+ + {(current) => ( + void props.handleToggle()} /> + )} + +
); } @@ -195,28 +198,45 @@ export function EmbeddingsSettings() {
- -
-
-
-

Keyword and network search are ready now

-

- Turn this on only if you want concept matching across synced posts. The model downloads locally and - nothing happens until you opt in. -

-
- - Off by default - +
+
+
+

+ + Semantic search is enabled + +

+

+ + Lazurite keeps the model on-device and uses it only for your local search index. + +

- + + Setting up}> + Ready + +
- + + +
- +

ETA: {formatEtaSeconds(value())}

}
- - {(message) =>

{message()}

} -
+ {(message) =>

{message()}

}
@@ -261,13 +279,14 @@ export function EmbeddingsSettings() { -

- Semantic search is optional. It stays off until you opt in, and it only improves local search over your synced - likes and bookmarks. -

-
- - +
+
+ +
+

+ Semantic search is optional. It only improves local search over synced likes and bookmarks, and reset clears + the downloaded model as part of a full wipe. +

); diff --git a/src/components/search/SearchPanel.tsx b/src/components/search/SearchPanel.tsx index 35355c0..dde0a2d 100644 --- a/src/components/search/SearchPanel.tsx +++ b/src/components/search/SearchPanel.tsx @@ -375,7 +375,7 @@ export function SearchPanel(props: SearchPanelProps = {}) { } return ( -
+
-