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: implement semantic search preflight/setup

+777 -148
+9 -6
docs/specs/search.md
··· 5 5 1. **Network search**: server-side search via Bluesky APIs - no local indexing. Always available. 6 6 2. **Local search**: full-text + semantic search over the **authenticated user's own** liked and bookmarked/saved posts, stored locally in SQLite. 7 7 8 - Local semantic search (embeddings) is **opt-out**: enabled by default, but can be disabled in settings. When disabled, only local keyword (FTS) search is available and the embedding model is not downloaded. 8 + Local semantic search (embeddings) is **opt-in** and **off by default**. Keyword search remains available without setup. When embeddings are enabled, Lazurite downloads the model locally and unlocks semantic and hybrid search for synced posts. 9 9 10 10 ## Network Search (not indexed) 11 11 ··· 77 77 1. **Sync**: on login and periodically, fetch the authenticated user's own likes (`app.bsky.feed.getActorLikes`) and bookmarks. Paginate using the API cursor, store posts in SQLite. 78 78 2. **Cursor persistence**: store the last-seen API cursor per `(did, source)` in the `sync_state` table. On subsequent syncs, resume from the stored cursor so we only fetch new posts - never re-fetch the full history. 79 79 3. **Index FTS**: insert post text into SQLite FTS5 virtual table for keyword search (always active). 80 - 4. **Embed** _(opt-out)_: run post text through `fastembed` with `nomic-embed-text-v1.5` (768-dim). Store vectors in `sqlite-vec` virtual table. Skipped when embeddings are disabled. 80 + 4. **Embed** _(optional)_: run post text through `fastembed` with `nomic-embed-text-v1.5` (768-dim). Store vectors in `sqlite-vec` virtual table. Skipped unless the user opts in. 81 81 5. **Reindex**: a manual "Reindex" action clears all embeddings from `posts_vec` and re-embeds every post. Useful after model updates or if the index becomes corrupted. 82 82 83 83 ## SQLite Schema ··· 108 108 -- Full-text search (always active) 109 109 CREATE VIRTUAL TABLE posts_fts USING fts5(text, uri UNINDEXED, content=posts, content_rowid=rowid); 110 110 111 - -- Vector embeddings (opt-out - only populated when embeddings enabled) 111 + -- Vector embeddings (optional - only populated when embeddings enabled) 112 112 CREATE VIRTUAL TABLE posts_vec USING vec0( 113 113 uri TEXT PRIMARY KEY, 114 114 embedding float[768] ··· 129 129 - Model: `nomic-embed-text-v1.5` via `fastembed` (ONNX runtime, no GPU required) 130 130 - Dimensions: 768 (or 256 with Matryoshka truncation for speed) 131 131 - Batch embedding on sync; single embedding on search query 132 - - Model downloaded on first use, cached in Tauri app data dir (skipped entirely when embeddings disabled) 132 + - Model downloaded after the user explicitly enables semantic search, cached in Tauri app data dir 133 133 134 134 ## Tauri Commands 135 135 ··· 155 155 sync_posts(did: String, source: "like"|"bookmark") -> SyncStatus // resumes from stored cursor 156 156 get_sync_status(did: String) -> SyncStatus 157 157 reindex_embeddings() -> () // clears & re-embeds all posts 158 - set_embeddings_enabled(enabled: bool) -> () // opt-out toggle 158 + set_embeddings_enabled(enabled: bool) -> () // explicit opt-in toggle 159 + set_embeddings_preflight_seen(seen: bool) -> () // dismiss first-run semantic-search setup 159 160 ``` 160 161 161 162 ## Keyboard Shortcuts ··· 172 173 - Mode switcher: `Motion` sliding indicator underline between tabs 173 174 - Sync status: animated progress bar during sync, `Presence` fade-out when complete 174 175 - Highlighted keyword matches in result text 175 - - Model download: progress bar on first launch with percentage + ETA 176 + - First Search visit can open a dedicated semantic-search preflight when embeddings are still off 177 + - Preflight explains that keyword/network search already work, embeddings are optional, and enabling downloads the model locally 178 + - Model download: progress bar with percentage + ETA while the local model is being prepared 176 179 - Empty state: illustration with prompt when no posts synced yet
+3 -3
docs/specs/settings.md
··· 24 24 | `notifications_desktop` | boolean | `true` | Show OS desktop notifications | 25 25 | `notifications_badge` | boolean | `true` | Show unread badge on app icon / tray | 26 26 | `notifications_sound` | boolean | `false` | Play sound on new notification | 27 - | `embeddings_enabled` | boolean | `true` | Enable semantic search (already exists) | 27 + | `embeddings_enabled` | boolean | `false` | Enable optional semantic search | 28 28 | `constellation_url` | string | `"https://constellation.microcosm.blue"` | Constellation instance base URL | 29 29 | `spacedust_url` | string | `"https://spacedust.microcosm.blue"` | Spacedust instance base URL | 30 30 | `spacedust_instant` | boolean | `false` | Bypass Spacedust 21-second debounce buffer | ··· 86 86 87 87 ### 5. Search & Embeddings 88 88 89 - - **Embeddings toggle**: opt-out of semantic search. When disabled, the embedding model is not downloaded and only keyword search is available. Toggling off does not delete existing embeddings — a separate "Clear embeddings" action handles that. 90 - - **Model status**: shows whether `nomic-embed-text-v1.5` is downloaded, its size on disk, and a "Download now" / "Remove model" action 89 + - **Embeddings toggle**: opt in to semantic search. It is off by default. When disabled, the embedding model is not downloaded and only keyword search is available. Toggling off does not delete existing embeddings — a separate "Clear embeddings" action handles that. 90 + - **Model status**: shows whether `nomic-embed-text-v1.5` is downloaded, its size/download cost, and retry state if setup fails 91 91 - **Reindex**: triggers a full re-embed of all synced posts. Shows progress bar during operation. 92 92 93 93 ### 6. Services
+9 -9
docs/tasks/07-search.md
··· 56 56 - `semantic`: embed query string → vec similarity search (requires embeddings enabled) 57 57 - `hybrid`: run both, merge via reciprocal rank fusion (falls back to keyword-only if embeddings disabled) 58 58 - [x] `get_sync_status(did)` → last sync time, post counts, cursor state 59 - - [x] Model management: download `nomic-embed-text-v1.5` ONNX on first use to `<app_data_dir>/models/` (skipped when embeddings disabled) 59 + - [x] Model management: download `nomic-embed-text-v1.5` ONNX after explicit opt-in to `<app_data_dir>/models/` (skipped when embeddings disabled) 60 60 - [x] Background sync: trigger after login, then every 15 min 61 61 62 62 ### Frontend ··· 68 68 69 69 #### Embeddings 70 70 71 - - [ ] embeddings opt-out toggle in settings (disables semantic search, skips model download) 72 - - [ ] model download progress bar (percentage + ETA) on first launch 73 - - Enabled by default (opt-out) 74 - - Splash/Preflight route should explain what semantic search provides 71 + - [x] embeddings opt-in toggle in settings/search UI (keeps semantic search off by default, skips model download until enabled) 72 + - [x] model download progress bar (percentage + ETA) during first semantic-search setup 73 + - Semantic search is off by default 74 + - Dedicated preflight route explains what semantic search provides before download starts 75 75 76 76 #### Sync Indexing 77 77 78 - - [ ] sync status indicator with animated progress bar, `Presence` fade-out on complete 79 - - [ ] reindex button: triggers `reindex_embeddings()`, shown in search settings or sync status area 80 - - [ ] empty state illustration when no posts synced yet 81 - - [ ] `Tab` cycles search mode (network → keyword → semantic → hybrid), `Escape` clears 78 + - [x] sync status indicator with animated progress bar, `Presence` fade-out on complete 79 + - [x] reindex button: triggers `reindex_embeddings()`, shown in search settings or sync status area 80 + - [x] empty state illustration when no posts synced yet 81 + - [x] `Tab` cycles search mode (network → keyword → semantic → hybrid), `Escape` clears
+5
src-tauri/src/commands/search.rs
··· 63 63 } 64 64 65 65 #[tauri::command] 66 + pub fn set_embeddings_preflight_seen(seen: bool, state: State<'_, AppState>) -> Result<()> { 67 + search::set_embeddings_preflight_seen(seen, &state) 68 + } 69 + 70 + #[tauri::command] 66 71 pub fn get_embeddings_enabled(state: State<'_, AppState>) -> Result<bool> { 67 72 search::get_embeddings_enabled(&state) 68 73 }
+44
src-tauri/src/db.rs
··· 53 53 "columns_expand_kinds", 54 54 include_str!("migrations/009_columns_expand_kinds.sql"), 55 55 ), 56 + Migration::new( 57 + 10, 58 + "embeddings_opt_in", 59 + include_str!("migrations/010_embeddings_opt_in.sql"), 60 + ), 56 61 ]; 57 62 58 63 pub fn initialize_database(app: &AppHandle) -> Result<DbPool, AppError> { ··· 280 285 ) 281 286 .expect("expanded schema should accept new column kinds"); 282 287 } 288 + } 289 + 290 + #[test] 291 + fn migration_ten_forces_embeddings_opt_in_defaults() { 292 + let connection = Connection::open_in_memory().expect("in-memory db should open"); 293 + connection 294 + .execute_batch(include_str!("migrations/006_app_settings.sql")) 295 + .expect("settings migration should apply"); 296 + 297 + let seeded_enabled: String = connection 298 + .query_row( 299 + "SELECT value FROM app_settings WHERE key = 'embeddings_enabled'", 300 + [], 301 + |row| row.get(0), 302 + ) 303 + .expect("embeddings_enabled should exist after migration 006"); 304 + assert_eq!(seeded_enabled, "1"); 305 + 306 + connection 307 + .execute_batch(include_str!("migrations/010_embeddings_opt_in.sql")) 308 + .expect("migration ten should apply"); 309 + 310 + let embeddings_enabled: String = connection 311 + .query_row( 312 + "SELECT value FROM app_settings WHERE key = 'embeddings_enabled'", 313 + [], 314 + |row| row.get(0), 315 + ) 316 + .expect("embeddings_enabled should exist after migration 010"); 317 + let preflight_seen: String = connection 318 + .query_row( 319 + "SELECT value FROM app_settings WHERE key = 'embeddings_preflight_seen'", 320 + [], 321 + |row| row.get(0), 322 + ) 323 + .expect("embeddings_preflight_seen should exist after migration 010"); 324 + 325 + assert_eq!(embeddings_enabled, "0"); 326 + assert_eq!(preflight_seen, "0"); 283 327 } 284 328 }
+1
src-tauri/src/lib.rs
··· 123 123 cmd::search::embed_pending_posts, 124 124 cmd::search::reindex_embeddings, 125 125 cmd::search::set_embeddings_enabled, 126 + cmd::search::set_embeddings_preflight_seen, 126 127 cmd::search::get_embeddings_enabled, 127 128 cmd::search::get_embeddings_config, 128 129 cmd::search::prepare_embeddings_model,
+6
src-tauri/src/migrations/010_embeddings_opt_in.sql
··· 1 + UPDATE app_settings 2 + SET value = '0' 3 + WHERE key = 'embeddings_enabled'; 4 + 5 + INSERT OR IGNORE INTO app_settings(key, value) VALUES ('embeddings_enabled', '0'); 6 + INSERT OR IGNORE INTO app_settings(key, value) VALUES ('embeddings_preflight_seen', '0');
+65 -12
src-tauri/src/search.rs
··· 35 35 const EMBEDDING_DIMENSIONS: i64 = 768; 36 36 const SEARCH_SYNC_CHECK_INTERVAL: Duration = Duration::from_secs(5); 37 37 const SEARCH_SYNC_INTERVAL: Duration = Duration::from_secs(15 * 60); 38 + const EMBEDDINGS_ENABLED_KEY: &str = "embeddings_enabled"; 39 + const EMBEDDINGS_PREFLIGHT_SEEN_KEY: &str = "embeddings_preflight_seen"; 38 40 static EMBEDDINGS_DOWNLOAD_STATE: LazyLock<Mutex<EmbeddingsDownloadState>> = 39 41 LazyLock::new(|| Mutex::new(EmbeddingsDownloadState::default())); 40 42 ··· 886 888 fn db_get_embeddings_enabled(conn: &Connection) -> Result<bool> { 887 889 let val: Option<String> = conn 888 890 .query_row( 889 - "SELECT value FROM app_settings WHERE key = 'embeddings_enabled'", 890 - [], 891 + "SELECT value FROM app_settings WHERE key = ?1", 892 + params![EMBEDDINGS_ENABLED_KEY], 891 893 |row| row.get(0), 892 894 ) 893 895 .optional()?; 894 - Ok(val.map(|v| v != "0").unwrap_or(true)) 896 + Ok(val.map(|v| v != "0").unwrap_or(false)) 895 897 } 896 898 897 899 fn db_set_embeddings_enabled(conn: &Connection, enabled: bool) -> Result<()> { 898 900 conn.execute( 899 - "INSERT INTO app_settings(key, value) VALUES('embeddings_enabled', ?1) 901 + "INSERT INTO app_settings(key, value) VALUES(?1, ?2) 900 902 ON CONFLICT(key) DO UPDATE SET value = excluded.value", 901 - params![if enabled { "1" } else { "0" }], 903 + params![EMBEDDINGS_ENABLED_KEY, if enabled { "1" } else { "0" }], 904 + )?; 905 + Ok(()) 906 + } 907 + 908 + fn db_get_embeddings_preflight_seen(conn: &Connection) -> Result<bool> { 909 + let val: Option<String> = conn 910 + .query_row( 911 + "SELECT value FROM app_settings WHERE key = ?1", 912 + params![EMBEDDINGS_PREFLIGHT_SEEN_KEY], 913 + |row| row.get(0), 914 + ) 915 + .optional()?; 916 + Ok(val.map(|value| value != "0").unwrap_or(false)) 917 + } 918 + 919 + fn db_set_embeddings_preflight_seen(conn: &Connection, seen: bool) -> Result<()> { 920 + conn.execute( 921 + "INSERT INTO app_settings(key, value) VALUES(?1, ?2) 922 + ON CONFLICT(key) DO UPDATE SET value = excluded.value", 923 + params![EMBEDDINGS_PREFLIGHT_SEEN_KEY, if seen { "1" } else { "0" }], 902 924 )?; 903 925 Ok(()) 904 926 } ··· 1243 1265 db_set_embeddings_enabled(&conn, enabled) 1244 1266 } 1245 1267 1268 + pub fn set_embeddings_preflight_seen(seen: bool, state: &AppState) -> Result<()> { 1269 + let conn = state.auth_store.lock_connection()?; 1270 + db_set_embeddings_preflight_seen(&conn, seen) 1271 + } 1272 + 1246 1273 /// Get the current embeddings-enabled preference. 1247 1274 pub fn get_embeddings_enabled(state: &AppState) -> Result<bool> { 1248 1275 let conn = state.auth_store.lock_connection()?; ··· 1253 1280 #[serde(rename_all = "camelCase")] 1254 1281 pub struct EmbeddingsConfig { 1255 1282 pub enabled: bool, 1283 + pub preflight_seen: bool, 1256 1284 pub model_name: String, 1257 1285 pub dimensions: i64, 1258 1286 pub model_size_bytes: Option<u64>, ··· 1270 1298 pub fn get_embeddings_config(app: &AppHandle, state: &AppState) -> Result<EmbeddingsConfig> { 1271 1299 let conn = state.auth_store.lock_connection()?; 1272 1300 let enabled = db_get_embeddings_enabled(&conn)?; 1301 + let preflight_seen = db_get_embeddings_preflight_seen(&conn)?; 1273 1302 let models_dir = resolve_models_dir(app)?; 1274 1303 let downloaded = embeddings_downloaded(&models_dir); 1275 1304 let model_size_bytes = directory_size(&models_dir).ok().filter(|bytes| *bytes > 0); ··· 1302 1331 1303 1332 Ok(EmbeddingsConfig { 1304 1333 enabled, 1334 + preflight_seen, 1305 1335 model_name: EMBEDDING_MODEL_NAME.to_string(), 1306 1336 dimensions: EMBEDDING_DIMENSIONS, 1307 1337 model_size_bytes, ··· 1397 1427 #[cfg(test)] 1398 1428 mod tests { 1399 1429 use super::{ 1400 - build_fts_match_query, build_search_posts_request, db_get_embeddings_enabled, db_list_saved_posts, 1401 - db_load_sync_cursor, db_post_count, db_save_sync_state, db_semantic_search, db_set_embeddings_enabled, 1402 - db_sync_status, db_upsert_embedding, db_upsert_post, normalize_identifier_filter, normalize_tag_filter, 1403 - run_local_search, storage_key, sync_due, validate_limit, validate_query, validate_search_mode, validate_source, 1404 - NetworkSearchQueryParams, SearchMode, 1430 + build_fts_match_query, build_search_posts_request, db_get_embeddings_enabled, db_get_embeddings_preflight_seen, 1431 + db_list_saved_posts, db_load_sync_cursor, db_post_count, db_save_sync_state, db_semantic_search, 1432 + db_set_embeddings_enabled, db_set_embeddings_preflight_seen, db_sync_status, db_upsert_embedding, 1433 + db_upsert_post, normalize_identifier_filter, normalize_tag_filter, run_local_search, storage_key, sync_due, 1434 + validate_limit, validate_query, validate_search_mode, validate_source, NetworkSearchQueryParams, SearchMode, 1405 1435 }; 1406 1436 use rusqlite::{ffi::sqlite3_auto_extension, Connection}; 1407 1437 use sqlite_vec::sqlite3_vec_init; ··· 1923 1953 } 1924 1954 1925 1955 #[test] 1926 - fn embeddings_enabled_defaults_to_true_when_row_absent() { 1956 + fn embeddings_enabled_defaults_to_false_when_row_absent() { 1927 1957 let conn = test_db(); 1928 - assert!(db_get_embeddings_enabled(&conn).unwrap()); 1958 + assert!(!db_get_embeddings_enabled(&conn).unwrap()); 1929 1959 } 1930 1960 1931 1961 #[test] ··· 1954 1984 db_set_embeddings_enabled(&conn, false).unwrap(); 1955 1985 db_set_embeddings_enabled(&conn, false).unwrap(); 1956 1986 assert!(!db_get_embeddings_enabled(&conn).unwrap()); 1987 + } 1988 + 1989 + #[test] 1990 + fn embeddings_preflight_seen_defaults_to_false_when_row_absent() { 1991 + let conn = test_db(); 1992 + assert!(!db_get_embeddings_preflight_seen(&conn).unwrap()); 1993 + } 1994 + 1995 + #[test] 1996 + fn set_embeddings_preflight_seen_persists() { 1997 + let conn = test_db(); 1998 + db_set_embeddings_preflight_seen(&conn, true).unwrap(); 1999 + assert!(db_get_embeddings_preflight_seen(&conn).unwrap()); 2000 + } 2001 + 2002 + #[test] 2003 + fn embeddings_preflight_seen_toggle_is_idempotent() { 2004 + let conn = test_db(); 2005 + db_set_embeddings_preflight_seen(&conn, true).unwrap(); 2006 + db_set_embeddings_preflight_seen(&conn, true).unwrap(); 2007 + assert!(db_get_embeddings_preflight_seen(&conn).unwrap()); 2008 + db_set_embeddings_preflight_seen(&conn, false).unwrap(); 2009 + assert!(!db_get_embeddings_preflight_seen(&conn).unwrap()); 1957 2010 } 1958 2011 1959 2012 #[test]
+16 -8
src-tauri/src/settings.rs
··· 116 116 notifications_desktop: true, 117 117 notifications_badge: true, 118 118 notifications_sound: false, 119 - embeddings_enabled: true, 119 + embeddings_enabled: false, 120 120 constellation_url: APP_DEFAULT_CONSTELLATION_URL.to_string(), 121 121 spacedust_url: APP_DEFAULT_SPACEDUST_URL.to_string(), 122 122 spacedust_instant: false, ··· 673 673 let conn = Connection::open_in_memory().expect("in-memory db should open"); 674 674 conn.execute_batch(include_str!("migrations/006_app_settings.sql")) 675 675 .expect("settings migration should apply"); 676 + conn.execute_batch(include_str!("migrations/010_embeddings_opt_in.sql")) 677 + .expect("opt-in migration should apply"); 676 678 conn 677 679 } 678 680 ··· 696 698 .expect("migration 006 should apply"); 697 699 conn.execute_batch(include_str!("migrations/007_search_owner_scope.sql")) 698 700 .expect("migration 007 should apply"); 701 + conn.execute_batch(include_str!("migrations/008_columns.sql")) 702 + .expect("migration 008 should apply"); 703 + conn.execute_batch(include_str!("migrations/009_columns_expand_kinds.sql")) 704 + .expect("migration 009 should apply"); 705 + conn.execute_batch(include_str!("migrations/010_embeddings_opt_in.sql")) 706 + .expect("migration 010 should apply"); 699 707 conn 700 708 } 701 709 ··· 719 727 assert!(settings.notifications_desktop); 720 728 assert!(settings.notifications_badge); 721 729 assert!(!settings.notifications_sound); 722 - assert!(settings.embeddings_enabled); 730 + assert!(!settings.embeddings_enabled); 723 731 assert_eq!(settings.constellation_url, "https://constellation.microcosm.blue"); 724 732 assert_eq!(settings.spacedust_url, "https://spacedust.microcosm.blue"); 725 733 assert!(!settings.spacedust_instant); ··· 728 736 } 729 737 730 738 #[test] 731 - fn migration_006_seeds_embeddings_enabled() { 739 + fn migration_010_disables_embeddings_by_default() { 732 740 let conn = settings_db(); 733 741 let settings = db_get_all_settings(&conn).expect("get_settings should succeed"); 734 742 assert!( 735 - settings.embeddings_enabled, 736 - "embeddings_enabled should default to true from seed" 743 + !settings.embeddings_enabled, 744 + "embeddings_enabled should default to false after opt-in migration" 737 745 ); 738 746 } 739 747 ··· 927 935 fn reset_app_re_seeds_embeddings_enabled() { 928 936 let conn = full_db(); 929 937 930 - conn.execute("UPDATE app_settings SET value='0' WHERE key='embeddings_enabled'", []) 938 + conn.execute("UPDATE app_settings SET value='1' WHERE key='embeddings_enabled'", []) 931 939 .expect("update should succeed"); 932 940 db_reset_app(&conn).expect("reset_app should succeed"); 933 941 ··· 940 948 .unwrap(); 941 949 assert_eq!( 942 950 val.as_deref(), 943 - Some("1"), 944 - "embeddings_enabled should be re-seeded to '1'" 951 + Some("0"), 952 + "embeddings_enabled should be re-seeded to '0'" 945 953 ); 946 954 } 947 955
+29 -20
src/components/search/EmbeddingsSettings.test.tsx
··· 6 6 const getEmbeddingsConfigMock = vi.hoisted(() => vi.fn()); 7 7 const prepareEmbeddingsModelMock = vi.hoisted(() => vi.fn()); 8 8 const setEmbeddingsEnabledMock = vi.hoisted(() => vi.fn()); 9 + const setEmbeddingsPreflightSeenMock = vi.hoisted(() => vi.fn()); 9 10 const getSettingsMock = vi.hoisted(() => vi.fn()); 10 11 const updateSettingMock = vi.hoisted(() => vi.fn()); 11 12 ··· 15 16 getEmbeddingsConfig: getEmbeddingsConfigMock, 16 17 prepareEmbeddingsModel: prepareEmbeddingsModelMock, 17 18 setEmbeddingsEnabled: setEmbeddingsEnabledMock, 19 + setEmbeddingsPreflightSeen: setEmbeddingsPreflightSeenMock, 18 20 }), 19 21 ); 20 22 ··· 35 37 getEmbeddingsConfigMock.mockReset(); 36 38 prepareEmbeddingsModelMock.mockReset(); 37 39 setEmbeddingsEnabledMock.mockReset(); 40 + setEmbeddingsPreflightSeenMock.mockReset(); 38 41 getSettingsMock.mockReset(); 39 42 updateSettingMock.mockReset(); 40 43 ··· 44 47 notificationsDesktop: true, 45 48 notificationsBadge: true, 46 49 notificationsSound: false, 47 - embeddingsEnabled: true, 50 + embeddingsEnabled: false, 48 51 constellationUrl: "https://constellation.microcosm.blue", 49 52 spacedustUrl: "https://spacedust.microcosm.blue", 50 53 spacedustInstant: false, ··· 52 55 globalShortcut: "Ctrl+Shift+N", 53 56 }); 54 57 getEmbeddingsConfigMock.mockResolvedValue({ 55 - enabled: true, 58 + enabled: false, 59 + preflightSeen: false, 56 60 modelName: "nomic-embed-text-v1.5", 57 61 dimensions: 768, 58 - modelSizeBytes: 1024 * 1024 * 384, 59 - downloaded: true, 62 + downloaded: false, 60 63 downloadActive: false, 61 64 }); 62 65 prepareEmbeddingsModelMock.mockResolvedValue({ 63 66 enabled: true, 67 + preflightSeen: true, 64 68 modelName: "nomic-embed-text-v1.5", 65 69 dimensions: 768, 66 70 modelSizeBytes: 1024 * 1024 * 384, ··· 73 77 it("renders embeddings settings with model info", async () => { 74 78 renderEmbeddingsSettings(); 75 79 76 - expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); 77 - expect(await screen.findByText(/nomic-embed-text-v1\.5/)).toBeInTheDocument(); 78 - expect(await screen.findByText(/768D/)).toBeInTheDocument(); 79 - expect(await screen.findByText(/384 MB on disk/i)).toBeInTheDocument(); 80 + expect(await screen.findByText("Optional Semantic Search")).toBeInTheDocument(); 81 + expect(await screen.findAllByText(/nomic-embed-text-v1\.5/)).toHaveLength(2); 82 + expect(await screen.findAllByText(/768D/)).toHaveLength(2); 83 + expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(2); 84 + expect(await screen.findAllByText(/off by default/i)).toHaveLength(2); 80 85 }); 81 86 82 - it("shows toggle in enabled state when embeddings are enabled", async () => { 87 + it("does not auto-download on mount while embeddings are off", async () => { 83 88 renderEmbeddingsSettings(); 84 89 85 - const toggle = await screen.findByRole("switch"); 86 - expect(toggle).toHaveAttribute("aria-checked", "true"); 90 + await screen.findByRole("switch"); 91 + expect(prepareEmbeddingsModelMock).not.toHaveBeenCalled(); 87 92 }); 88 93 89 94 it("shows toggle in disabled state when embeddings are disabled", async () => { 90 95 getEmbeddingsConfigMock.mockResolvedValue({ 91 96 enabled: false, 97 + preflightSeen: false, 92 98 modelName: "nomic-embed-text-v1.5", 93 99 dimensions: 768, 94 100 downloaded: false, ··· 103 109 104 110 it("toggles embeddings when clicking the switch", async () => { 105 111 getEmbeddingsConfigMock.mockResolvedValueOnce({ 106 - enabled: true, 112 + enabled: false, 113 + preflightSeen: false, 107 114 modelName: "nomic-embed-text-v1.5", 108 115 dimensions: 768, 109 - downloaded: true, 116 + downloaded: false, 110 117 downloadActive: false, 111 118 }).mockResolvedValueOnce({ 112 - enabled: false, 119 + enabled: true, 120 + preflightSeen: true, 113 121 modelName: "nomic-embed-text-v1.5", 114 122 dimensions: 768, 115 123 downloaded: false, ··· 119 127 renderEmbeddingsSettings(); 120 128 121 129 const toggle = await screen.findByRole("switch"); 122 - expect(toggle).toHaveAttribute("aria-checked", "true"); 130 + expect(toggle).toHaveAttribute("aria-checked", "false"); 123 131 124 132 fireEvent.click(toggle); 125 133 126 134 await waitFor(() => { 127 - expect(setEmbeddingsEnabledMock).toHaveBeenCalledWith(false); 135 + expect(setEmbeddingsEnabledMock).toHaveBeenCalledWith(true); 128 136 }); 129 137 130 138 await waitFor(() => { 131 - expect(toggle).toHaveAttribute("aria-checked", "false"); 139 + expect(prepareEmbeddingsModelMock).toHaveBeenCalled(); 132 140 }); 133 141 }); 134 142 135 143 it("shows download progress when model is not downloaded", async () => { 136 144 getEmbeddingsConfigMock.mockResolvedValue({ 137 145 enabled: true, 146 + preflightSeen: true, 138 147 modelName: "nomic-embed-text-v1.5", 139 148 dimensions: 768, 140 149 downloaded: false, ··· 153 162 154 163 it("displays semantic search description", async () => { 155 164 renderEmbeddingsSettings(); 156 - expect(await screen.findByText(/conceptually similar posts/i)).toBeInTheDocument(); 165 + expect(await screen.findByText(/semantic search is optional/i)).toBeInTheDocument(); 157 166 }); 158 167 159 168 it("handles errors when loading config gracefully", async () => { ··· 164 173 await waitFor(() => { 165 174 expect(getEmbeddingsConfigMock).toHaveBeenCalled(); 166 175 }); 167 - expect(await screen.findByText("Semantic Search")).toBeInTheDocument(); 176 + expect(await screen.findByText("Optional Semantic Search")).toBeInTheDocument(); 168 177 }); 169 178 170 179 it("handles errors when toggling gracefully", async () => { ··· 179 188 expect(setEmbeddingsEnabledMock).toHaveBeenCalled(); 180 189 }); 181 190 182 - expect(toggle).toHaveAttribute("aria-checked", "true"); 191 + expect(toggle).toHaveAttribute("aria-checked", "false"); 183 192 }); 184 193 });
+64 -50
src/components/search/EmbeddingsSettings.tsx
··· 6 6 import { createEffect, createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js"; 7 7 import { Motion, Presence } from "solid-motionone"; 8 8 9 + const ESTIMATED_MODEL_SIZE_BYTES = 1024 * 1024 * 384; 10 + 9 11 function ModelDescriptor(props: { config: EmbeddingsConfig | null }) { 10 12 return ( 11 - <p class="m-0 text-xs text-on-surface-variant flex items-center gap-2"> 13 + <p class="m-0 flex flex-wrap items-center gap-2 text-xs text-on-surface-variant"> 12 14 <span>{props.config?.modelName ?? "nomic-embed-text-v1.5"}</span> 13 15 <span>·</span> 14 16 <span>{props.config?.dimensions ?? 768}D</span> 15 - <Show when={props.config?.modelSizeBytes}> 16 - {(bytes) => ( 17 - <> 18 - <span>·</span> 19 - <span>{formatBytes(bytes())} on disk</span> 20 - </> 21 - )} 22 - </Show> 17 + <span>·</span> 18 + <span>{formatBytes(props.config?.modelSizeBytes ?? ESTIMATED_MODEL_SIZE_BYTES)} download</span> 23 19 </p> 24 20 ); 25 21 } 26 22 27 23 function EmbedSettingsHeader(props: { config: EmbeddingsConfig | null; isLoading: boolean; handleToggle: () => void }) { 28 24 return ( 29 - <div class="flex items-center gap-4 justify-between"> 30 - <div class="flex gap-2 items-center"> 25 + <div class="flex items-start justify-between gap-4"> 26 + <div class="flex items-start gap-3"> 31 27 <Icon 32 28 kind="search" 33 - class="text-lg text-primary h-11 w-11 items-center justify-center rounded-full bg-primary/15" /> 29 + class="h-11 w-11 items-center justify-center rounded-full bg-primary/15 text-lg text-primary" /> 34 30 35 - <p class="text-lg font-medium text-on-surface">Semantic Search</p> 31 + <div class="grid gap-1"> 32 + <p class="m-0 text-lg font-medium text-on-surface">Optional Semantic Search</p> 33 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant"> 34 + Off by default. Turn this on to download a local model and unlock semantic plus hybrid search for synced 35 + posts. 36 + </p> 37 + </div> 36 38 </div> 37 39 38 40 <Show when={props.config}> ··· 54 56 onClick={() => void props.prepareModel()} 55 57 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"> 56 58 <Icon kind="download" /> 57 - <Show when={props.config?.lastError} fallback="Prepare model">Retry download</Show> 59 + <Show when={props.config?.lastError} fallback="Retry download">Download model again</Show> 58 60 </button> 59 61 ); 60 62 } ··· 79 81 80 82 function StatusLabel(props: { config: EmbeddingsConfig | null }) { 81 83 return ( 82 - <span class="font-medium text-sm"> 84 + <span class="text-sm font-medium"> 83 85 <Switch fallback={<span class="text-on-surface">Loading...</span>}> 84 86 <Match when={props.config?.downloadActive}> 85 87 <span class="text-primary">Downloading model files...</span> 86 88 </Match> 87 89 <Match when={props.config?.downloaded}> 88 - <span class="text-emerald-300">Model ready</span> 90 + <span class="text-emerald-300">Semantic search ready</span> 89 91 </Match> 90 92 <Match when={props.config?.lastError}> 91 93 <span class="text-red-300">Download failed</span> 92 94 </Match> 93 95 <Match when={props.config?.enabled}> 94 - <span class="text-primary">Preparing model cache...</span> 96 + <span class="text-primary">Preparing semantic search...</span> 95 97 </Match> 96 98 <Match when={!props.config?.enabled}> 97 99 <span class="text-on-surface-variant">Semantic search is off</span> ··· 111 113 </Match> 112 114 <Match when={props.config?.downloaded}> 113 115 <Icon kind="complete" class="text-emerald-300" /> 114 - <span>Model ready</span> 116 + <span>Semantic search ready</span> 115 117 </Match> 116 118 <Match when={props.config?.lastError}> 117 119 <Icon kind="danger" class="text-red-300" /> ··· 119 121 </Match> 120 122 <Match when={props.config?.enabled}> 121 123 <Icon kind="download" class="text-primary" /> 122 - <span>Preparing model cache...</span> 124 + <span>Preparing semantic search...</span> 123 125 </Match> 124 126 <Match when={!props.config?.enabled}> 125 127 <Icon kind="close" class="text-on-surface-variant" /> ··· 132 134 133 135 export function EmbeddingsSettings() { 134 136 const preferences = useAppPreferences(); 135 - const [autoPrepareStarted, setAutoPrepareStarted] = createSignal(false); 137 + const [prepareRequested, setPrepareRequested] = createSignal(false); 136 138 const config = () => preferences.embeddingsConfig; 137 139 138 140 async function prepareModel() { 139 - await preferences.prepareEmbeddingsModel(); 141 + setPrepareRequested(true); 142 + 143 + try { 144 + await preferences.prepareEmbeddingsModel(); 145 + } finally { 146 + setPrepareRequested(false); 147 + } 140 148 } 141 149 142 150 async function handleToggle() { ··· 147 155 148 156 const nextEnabled = !current.enabled; 149 157 await preferences.setEmbeddingsEnabled(nextEnabled); 150 - if (!nextEnabled) { 151 - setAutoPrepareStarted(false); 158 + 159 + if (nextEnabled) { 160 + void prepareModel(); 161 + return; 152 162 } 163 + 164 + setPrepareRequested(false); 153 165 } 154 166 155 - const ofProgress = createMemo<[number, number] | null>(() => { 167 + const fileProgress = createMemo<[number, number] | null>(() => { 156 168 const index = config()?.downloadFileIndex; 157 169 const total = config()?.downloadFileTotal; 158 170 ··· 169 181 } 170 182 }); 171 183 172 - createEffect(() => { 173 - const current = config(); 174 - if (!current) { 175 - return; 176 - } 177 - 178 - if (!current.enabled) { 179 - setAutoPrepareStarted(false); 180 - return; 181 - } 182 - 183 - if (current.downloaded || current.downloadActive || autoPrepareStarted()) { 184 - return; 185 - } 186 - 187 - setAutoPrepareStarted(true); 188 - void prepareModel(); 189 - }); 190 - 191 184 onMount(() => { 192 185 const interval = setInterval(() => { 193 - if (preferences.embeddingsConfig?.downloadActive) { 186 + if (prepareRequested() || preferences.embeddingsConfig?.downloadActive) { 194 187 void preferences.loadEmbeddingsConfig(); 195 188 } 196 189 }, 1000); ··· 202 195 <section class="panel-surface grid gap-4 p-5"> 203 196 <EmbedSettingsHeader config={config()} isLoading={preferences.embeddingsLoading} handleToggle={handleToggle} /> 204 197 198 + <Show when={!config()?.enabled}> 199 + <div class="grid gap-3 rounded-3xl bg-white/[0.035] p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 200 + <div class="flex items-center justify-between gap-3"> 201 + <div class="grid gap-1"> 202 + <p class="m-0 text-sm font-medium text-on-surface">Keyword and network search are ready now</p> 203 + <p class="m-0 text-xs text-on-surface-variant"> 204 + Turn this on only if you want concept matching across synced posts. The model downloads locally and 205 + nothing happens until you opt in. 206 + </p> 207 + </div> 208 + <span class="rounded-full bg-primary/10 px-2.5 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-primary"> 209 + Off by default 210 + </span> 211 + </div> 212 + <ModelDescriptor config={config()} /> 213 + </div> 214 + </Show> 215 + 205 216 <Presence> 206 - <Show when={config()?.enabled && (!config()?.downloaded || config()?.downloadActive || config()?.lastError)}> 217 + <Show when={config()?.enabled && (!config()?.downloaded || config()?.downloadActive || config()?.lastError || prepareRequested())}> 207 218 <Motion.div 208 219 class="grid gap-3 rounded-2xl bg-white/5 p-4" 209 220 initial={{ opacity: 0, height: 0 }} ··· 227 238 {(filename) => <p class="m-0">Current file: {filename().split("/").at(-1) ?? filename()}</p>} 228 239 </Show> 229 240 230 - <Show when={ofProgress()}> 241 + <Show when={fileProgress()}> 231 242 {(value) => { 232 243 const [index, total] = value(); 233 244 return <p class="m-0">File {index} of {total}</p>; ··· 235 246 </Show> 236 247 237 248 <Show when={config()?.downloadEtaSeconds}> 238 - {value => <p class="m-0">ETA: {formatEtaSeconds(value())}</p>} 249 + {(value) => <p class="m-0">ETA: {formatEtaSeconds(value())}</p>} 239 250 </Show> 240 251 241 - <Show when={config()?.lastError}>{(message) => <p class="m-0 text-red-300">{message()}</p>}</Show> 252 + <Show when={config()?.lastError}> 253 + {(message) => <p class="m-0 text-red-300">{message()}</p>} 254 + </Show> 242 255 </div> 243 256 244 - <Show when={!config()?.downloadActive && !config()?.downloaded}> 257 + <Show when={!config()?.downloadActive && !prepareRequested() && !config()?.downloaded}> 245 258 <DownloadButton config={config()} prepareModel={prepareModel} /> 246 259 </Show> 247 260 </Motion.div> ··· 249 262 </Presence> 250 263 251 264 <p class="m-0 text-xs leading-relaxed text-on-surface-variant/80"> 252 - Semantic search can find conceptually similar posts even when they do not contain the exact keywords you typed. 265 + Semantic search is optional. It stays off until you opt in, and it only improves local search over your synced 266 + likes and bookmarks. 253 267 </p> 254 268 <div class="flex items-center gap-2"> 255 269 <StatusLabel config={config()} />
+12
src/components/search/SearchEmptyState.test.tsx
··· 1 + import { render, screen } from "@solidjs/testing-library"; 2 + import { describe, expect, it } from "vitest"; 3 + import { SearchEmptyState } from "./SearchEmptyState"; 4 + 5 + describe("SearchEmptyState", () => { 6 + it("renders the composed no-sync illustration", () => { 7 + render(() => <SearchEmptyState reason="no-sync" scope="local" />); 8 + 9 + expect(screen.getByTestId("no-sync-illustration")).toBeInTheDocument(); 10 + expect(screen.getByText(/run a sync to fill local search/i)).toBeInTheDocument(); 11 + }); 12 + });
+56 -8
src/components/search/SearchEmptyState.tsx
··· 8 8 export function SearchEmptyState(props: SearchEmptyStateProps) { 9 9 return ( 10 10 <div class="text-center"> 11 - <EmptyStateIcon reason={props.reason} /> 11 + <EmptyStateVisual reason={props.reason} /> 12 12 <EmptyStateContent reason={props.reason} scope={props.scope ?? "local"} /> 13 13 </div> 14 14 ); 15 15 } 16 16 17 - function EmptyStateIcon(props: { reason: string }) { 17 + function EmptyStateVisual(props: { reason: string }) { 18 + return ( 19 + <Show when={props.reason === "no-sync"} fallback={<EmptyStateIcon />}> 20 + <NoSyncIllustration /> 21 + </Show> 22 + ); 23 + } 24 + 25 + function EmptyStateIcon() { 18 26 return ( 19 27 <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)]"> 20 - <Show 21 - when={props.reason === "no-sync"} 22 - fallback={<Icon kind="search" class="text-3xl text-on-surface-variant" />}> 23 - <Icon kind="db" class="text-3xl text-on-surface-variant" /> 24 - </Show> 28 + <Icon kind="search" class="text-3xl text-on-surface-variant" /> 29 + </div> 30 + ); 31 + } 32 + 33 + function NoSyncIllustration() { 34 + return ( 35 + <div 36 + data-testid="no-sync-illustration" 37 + class="relative mx-auto mb-6 h-40 w-full max-w-xs overflow-hidden rounded-[2rem] bg-white/[0.025] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 38 + <div class="absolute inset-x-6 top-5 h-16 rounded-[1.25rem] bg-primary/10 blur-2xl" /> 39 + <div class="absolute left-5 top-7 w-26 rounded-[1.4rem] bg-surface-container p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 40 + <div class="mb-2 flex items-center gap-2"> 41 + <span class="flex h-8 w-8 items-center justify-center rounded-xl bg-primary/14 text-primary"> 42 + <Icon kind="bookmark" class="text-base" /> 43 + </span> 44 + <span class="h-2.5 w-12 rounded-full bg-white/8" /> 45 + </div> 46 + <div class="grid gap-1.5"> 47 + <span class="h-2 rounded-full bg-white/7" /> 48 + <span class="h-2 w-4/5 rounded-full bg-white/5" /> 49 + </div> 50 + </div> 51 + 52 + <div class="absolute right-5 top-10 w-28 rounded-[1.4rem] bg-surface-container-high p-3 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 53 + <div class="mb-3 flex items-center justify-between"> 54 + <span class="flex h-8 w-8 items-center justify-center rounded-xl bg-white/8 text-on-surface-variant"> 55 + <Icon kind="db" class="text-base" /> 56 + </span> 57 + <span class="rounded-full bg-white/8 px-2 py-0.5 text-[0.62rem] uppercase tracking-[0.12em] text-on-surface-variant"> 58 + local 59 + </span> 60 + </div> 61 + <div class="grid gap-1.5"> 62 + <span class="h-2 rounded-full bg-white/7" /> 63 + <span class="h-2 w-3/4 rounded-full bg-primary/18" /> 64 + <span class="h-2 w-2/3 rounded-full bg-white/5" /> 65 + </div> 66 + </div> 67 + 68 + <div class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-black/35 px-3 py-1.5 text-[0.68rem] text-on-surface-variant shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]"> 69 + <Icon kind="refresh" class="text-primary" /> 70 + <span>Run a sync to fill local search</span> 71 + </div> 25 72 </div> 26 73 ); 27 74 } ··· 127 174 <> 128 175 <h3 class="mb-1 text-base font-medium text-on-surface">No posts synced yet</h3> 129 176 <p class="m-0 text-sm text-on-surface-variant"> 130 - Sync your liked and bookmarked posts to build the local index for keyword and semantic search. 177 + Sync your liked and bookmarked posts to build the local index for keyword search now, then optionally unlock 178 + semantic search later. 131 179 </p> 132 180 </> 133 181 );
+14 -8
src/components/search/SearchPanel.tsx
··· 117 117 const isActorTab = createMemo(() => routeState().tab === "profiles"); 118 118 const isLocalMode = createMemo(() => routeState().tab === "posts" && routeState().mode !== "network"); 119 119 const networkFiltersEnabled = createMemo(() => routeState().tab === "posts" && routeState().mode === "network"); 120 - const semanticEnabled = createMemo(() => preferences.embeddingsEnabled); 120 + const semanticEnabled = createMemo(() => 121 + !!preferences.embeddingsConfig?.enabled && !!preferences.embeddingsConfig?.downloaded 122 + ); 121 123 const totalIndexedPosts = createMemo(() => 122 124 search.syncStatus.reduce((sum, status) => sum + (status.postCount ?? 0), 0) 123 125 ); ··· 130 132 131 133 return formatRelativeTime(timestamps.toSorted((left, right) => right.localeCompare(left))[0]); 132 134 }); 133 - const cycleModes = createMemo(() => MODES.filter((candidate) => candidate !== "semantic" || semanticEnabled())); 135 + const cycleModes = createMemo(() => 136 + MODES.filter((candidate) => semanticEnabled() || (candidate !== "semantic" && candidate !== "hybrid")) 137 + ); 134 138 135 139 async function performSearch() { 136 140 const state = routeState(); ··· 172 176 return; 173 177 } 174 178 175 - if (state.mode === "semantic" && !semanticEnabled()) { 179 + if ((state.mode === "semantic" || state.mode === "hybrid") && !semanticEnabled()) { 176 180 setSearch({ 177 181 actorResults: null, 178 - error: "Semantic search is disabled. Re-enable embeddings to use this mode.", 182 + error: "Semantic search is optional and currently off. Use Search setup or Settings to enable embeddings.", 179 183 hasSearched: true, 180 184 networkResults: null, 181 185 resultCount: 0, ··· 245 249 } 246 250 247 251 function handleModeChange(newMode: SearchMode) { 248 - if (newMode === "semantic" && !semanticEnabled()) { 252 + if ((newMode === "semantic" || newMode === "hybrid") && !semanticEnabled()) { 249 253 return; 250 254 } 251 255 ··· 353 357 }); 354 358 355 359 createEffect(() => { 356 - if (routeState().mode === "semantic" && !semanticEnabled()) { 360 + if ((routeState().mode === "semantic" || routeState().mode === "hybrid") && !semanticEnabled()) { 357 361 replaceRoute({ mode: "keyword" }); 358 362 } 359 363 }); ··· 657 661 type="button" 658 662 aria-pressed={props.activeMode === searchMode} 659 663 disabled={disabled} 660 - title={disabled ? "Enable embeddings to use semantic search." : undefined} 664 + title={disabled 665 + ? "Semantic and hybrid search stay unavailable until you opt into embeddings from Search setup or Settings." 666 + : undefined} 661 667 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" 662 668 classList={{ 663 669 "text-primary": props.activeMode === searchMode, ··· 892 898 </div> 893 899 <div class="m-0 flex items-start gap-2"> 894 900 <div>·</div> 895 - <div>Use keyword mode for exact terms and hybrid mode for broader recall.</div> 901 + <div>Use keyword mode for exact terms. Hybrid becomes available after embeddings finish setting up.</div> 896 902 </div> 897 903 <div class="m-0 flex items-start gap-2"> 898 904 <div>·</div>
+268
src/components/search/SearchPreflightPanel.tsx
··· 1 + import { Icon } from "$/components/shared/Icon"; 2 + import { useAppPreferences } from "$/contexts/app-preferences"; 3 + import { normalizeSearchReturnRoute } from "$/lib/search-routes"; 4 + import { formatBytes, formatEtaSeconds, formatProgress } from "$/lib/utils/text"; 5 + import { useLocation, useNavigate } from "@solidjs/router"; 6 + import * as logger from "@tauri-apps/plugin-log"; 7 + import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"; 8 + import { Motion, Presence } from "solid-motionone"; 9 + 10 + const ESTIMATED_MODEL_SIZE_BYTES = 1024 * 1024 * 384; 11 + 12 + function SearchCapabilityCard( 13 + props: { body: string; icon: "search" | "explore" | "download"; title: string; tone?: "default" | "primary" }, 14 + ) { 15 + return ( 16 + <div 17 + class="grid gap-3 rounded-3xl p-4 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]" 18 + classList={{ 19 + "bg-white/[0.035]": props.tone !== "primary", 20 + "bg-primary/10": props.tone === "primary", 21 + }}> 22 + <div class="flex items-center gap-3"> 23 + <span 24 + class="flex h-11 w-11 items-center justify-center rounded-2xl" 25 + classList={{ 26 + "bg-white/7 text-on-surface": props.tone !== "primary", 27 + "bg-primary/18 text-primary": props.tone === "primary", 28 + }}> 29 + <Icon kind={props.icon} class="text-lg" /> 30 + </span> 31 + <p class="m-0 text-sm font-medium text-on-surface">{props.title}</p> 32 + </div> 33 + <p class="m-0 text-sm leading-relaxed text-on-surface-variant">{props.body}</p> 34 + </div> 35 + ); 36 + } 37 + 38 + export function SearchPreflightPanel() { 39 + const preferences = useAppPreferences(); 40 + const location = useLocation(); 41 + const navigate = useNavigate(); 42 + const [activating, setActivating] = createSignal(false); 43 + const [prepareRequested, setPrepareRequested] = createSignal(false); 44 + 45 + const config = () => preferences.embeddingsConfig; 46 + const returnRoute = createMemo(() => normalizeSearchReturnRoute(new URLSearchParams(location.search).get("next"))); 47 + const modelSizeLabel = createMemo(() => formatBytes(config()?.modelSizeBytes ?? ESTIMATED_MODEL_SIZE_BYTES)); 48 + const fileProgress = createMemo<[number, number] | null>(() => { 49 + const index = config()?.downloadFileIndex; 50 + const total = config()?.downloadFileTotal; 51 + 52 + if (typeof index === "number" && typeof total === "number" && total > 0) { 53 + return [index, total]; 54 + } 55 + 56 + return null; 57 + }); 58 + 59 + async function dismissPreflight() { 60 + await preferences.setEmbeddingsPreflightSeen(true); 61 + void navigate(returnRoute()); 62 + } 63 + 64 + async function enableSemanticSearch() { 65 + if (activating()) { 66 + return; 67 + } 68 + 69 + setActivating(true); 70 + setPrepareRequested(true); 71 + 72 + try { 73 + await preferences.setEmbeddingsEnabled(true); 74 + await preferences.setEmbeddingsPreflightSeen(true); 75 + await preferences.prepareEmbeddingsModel(); 76 + void navigate(returnRoute()); 77 + } catch (error) { 78 + logger.error("failed to enable semantic search", { keyValues: { error: String(error) } }); 79 + } finally { 80 + setActivating(false); 81 + setPrepareRequested(false); 82 + } 83 + } 84 + 85 + createEffect(() => { 86 + if (!config() && !preferences.embeddingsLoading) { 87 + void preferences.loadEmbeddingsConfig(); 88 + } 89 + }); 90 + 91 + onMount(() => { 92 + const interval = setInterval(() => { 93 + if (prepareRequested() || preferences.embeddingsConfig?.downloadActive) { 94 + void preferences.loadEmbeddingsConfig(); 95 + } 96 + }, 1000); 97 + 98 + onCleanup(() => clearInterval(interval)); 99 + }); 100 + 101 + return ( 102 + <article 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)]"> 103 + <header class="grid gap-4 px-6 pb-4 pt-6"> 104 + <div class="grid gap-2"> 105 + <p class="overline-copy text-xs text-on-surface-variant">Optional Setup</p> 106 + <div class="flex items-start justify-between gap-4"> 107 + <div class="grid gap-2"> 108 + <h1 class="m-0 text-2xl font-semibold tracking-tight text-on-surface">Semantic search is optional</h1> 109 + <p class="m-0 max-w-2xl text-sm leading-relaxed text-on-surface-variant"> 110 + Keyword and network search work right away. If you want concept matching across your synced likes and 111 + bookmarks, you can opt into local embeddings. They stay off by default. 112 + </p> 113 + </div> 114 + 115 + <button 116 + type="button" 117 + onClick={() => void dismissPreflight()} 118 + class="inline-flex h-10 w-10 items-center justify-center rounded-full border-0 bg-white/6 text-on-surface-variant transition hover:bg-white/10 hover:text-on-surface" 119 + title="Skip semantic search setup"> 120 + <Icon kind="close" class="text-lg" /> 121 + </button> 122 + </div> 123 + </div> 124 + </header> 125 + 126 + <div class="min-h-0 overflow-y-auto px-6 pb-6"> 127 + <Motion.div 128 + class="mx-auto grid max-w-4xl gap-6" 129 + initial={{ opacity: 0, y: 20 }} 130 + animate={{ opacity: 1, y: 0 }} 131 + transition={{ duration: 0.28 }}> 132 + <section class="grid gap-4 lg:grid-cols-[1.25fr_0.95fr]"> 133 + <div class="grid gap-4 rounded-[2rem] bg-black/30 p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 134 + <div class="flex items-center justify-between gap-4"> 135 + <div class="grid gap-1"> 136 + <p class="m-0 text-sm font-medium text-on-surface">What changes if you enable it</p> 137 + <p class="m-0 text-xs text-on-surface-variant"> 138 + Downloads {modelSizeLabel()} of local model files and unlocks semantic + hybrid modes. 139 + </p> 140 + </div> 141 + <span class="rounded-full bg-primary/12 px-3 py-1 text-[0.68rem] font-medium uppercase tracking-[0.12em] text-primary"> 142 + Off by default 143 + </span> 144 + </div> 145 + 146 + <div class="grid gap-3 md:grid-cols-2"> 147 + <SearchCapabilityCard 148 + icon="search" 149 + title="Keyword search stays available" 150 + body="Search exact words across the posts you already synced. No model download required." /> 151 + <SearchCapabilityCard 152 + icon="explore" 153 + title="Semantic search becomes available" 154 + body="Find related ideas even when a post does not use the exact phrase you typed." 155 + tone="primary" /> 156 + </div> 157 + </div> 158 + 159 + <div class="grid gap-4 rounded-[2rem] bg-white/[0.035] p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 160 + <div class="grid gap-2"> 161 + <p class="m-0 text-sm font-medium text-on-surface">What happens next</p> 162 + <div class="grid gap-2 text-sm text-on-surface-variant"> 163 + <p class="m-0">1. Turn on semantic search.</p> 164 + <p class="m-0">2. Download the local model immediately.</p> 165 + <p class="m-0">3. Use semantic or hybrid mode from Search once setup completes.</p> 166 + </div> 167 + </div> 168 + 169 + <div class="grid gap-2 rounded-2xl bg-black/30 p-4 text-xs text-on-surface-variant"> 170 + <p class="m-0 flex items-center gap-2"> 171 + <Icon kind="db" class="text-primary" /> 172 + Existing synced posts stay local and can still be searched by keyword. 173 + </p> 174 + <p class="m-0 flex items-center gap-2"> 175 + <Icon kind="download" class="text-primary" /> 176 + If the model is already cached, re-enabling reuses it instead of downloading again. 177 + </p> 178 + </div> 179 + </div> 180 + </section> 181 + 182 + <Presence> 183 + <Show when={config()?.enabled && (prepareRequested() || config()?.downloadActive || config()?.lastError || !config()?.downloaded)}> 184 + <Motion.section 185 + class="grid gap-3 rounded-[2rem] bg-primary/8 p-5 shadow-[inset_0_0_0_1px_rgba(125,175,255,0.12)]" 186 + initial={{ opacity: 0, height: 0 }} 187 + animate={{ opacity: 1, height: "auto" }} 188 + exit={{ opacity: 0, height: 0 }} 189 + transition={{ duration: 0.22 }}> 190 + <div class="flex items-center justify-between gap-3"> 191 + <div class="flex items-center gap-3"> 192 + <span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary/14 text-primary"> 193 + <Icon kind={config()?.lastError ? "danger" : "download"} class="text-lg" /> 194 + </span> 195 + <div class="grid gap-1"> 196 + <p class="m-0 text-sm font-medium text-on-surface"> 197 + {config()?.lastError ? "Download needs attention" : "Preparing semantic search"} 198 + </p> 199 + <p class="m-0 text-xs text-on-surface-variant"> 200 + {config()?.lastError 201 + ? "The download stopped before semantic search became available." 202 + : "Model files are downloading in the background so semantic search can turn on."} 203 + </p> 204 + </div> 205 + </div> 206 + <span class="text-xs text-on-surface-variant">{formatProgress(config()?.downloadProgress)}</span> 207 + </div> 208 + 209 + <div class="h-2 overflow-hidden rounded-full bg-black/30"> 210 + <Motion.div 211 + class="h-full rounded-full bg-linear-to-r from-primary to-primary-dim" 212 + animate={{ width: `${Math.max(config()?.downloadProgress ?? 2, 2)}%` }} 213 + transition={{ duration: 0.25 }} /> 214 + </div> 215 + 216 + <div class="grid gap-1 text-xs text-on-surface-variant"> 217 + <Show when={config()?.downloadFile}> 218 + {(filename) => <p class="m-0">Current file: {filename().split("/").at(-1) ?? filename()}</p>} 219 + </Show> 220 + <Show when={fileProgress()}> 221 + {(value) => { 222 + const [index, total] = value(); 223 + return <p class="m-0">File {index} of {total}</p>; 224 + }} 225 + </Show> 226 + <Show when={config()?.downloadEtaSeconds}> 227 + {(seconds) => <p class="m-0">ETA: {formatEtaSeconds(seconds())}</p>} 228 + </Show> 229 + <Show when={config()?.lastError}> 230 + {(message) => <p class="m-0 text-red-200">{message()}</p>} 231 + </Show> 232 + </div> 233 + </Motion.section> 234 + </Show> 235 + </Presence> 236 + 237 + <section class="flex flex-wrap items-center justify-between gap-3 rounded-[2rem] bg-white/[0.025] p-5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]"> 238 + <div class="grid gap-1"> 239 + <p class="m-0 text-sm font-medium text-on-surface">You can change this later from Search or Settings.</p> 240 + <p class="m-0 text-xs text-on-surface-variant"> 241 + Continue with regular search now, or opt in and let the download finish before returning. 242 + </p> 243 + </div> 244 + 245 + <div class="flex flex-wrap items-center gap-2"> 246 + <button 247 + type="button" 248 + onClick={() => void dismissPreflight()} 249 + disabled={activating()} 250 + class="inline-flex items-center gap-2 rounded-full border-0 bg-white/7 px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-50"> 251 + <Icon kind="close" class="text-sm" /> 252 + <span>Continue without semantic search</span> 253 + </button> 254 + <button 255 + type="button" 256 + onClick={() => void enableSemanticSearch()} 257 + disabled={activating()} 258 + class="inline-flex items-center gap-2 rounded-full border-0 bg-primary px-4 py-2 text-sm font-medium text-on-primary-fixed transition hover:bg-primary-dim disabled:cursor-not-allowed disabled:opacity-50"> 259 + <Icon kind={activating() ? "loader" : "download"} iconClass={activating() ? "i-ri-loader-4-line animate-spin" : undefined} class="text-sm" /> 260 + <span>{activating() ? "Downloading model..." : "Enable semantic search"}</span> 261 + </button> 262 + </div> 263 + </section> 264 + </Motion.div> 265 + </div> 266 + </article> 267 + ); 268 + }
+31 -4
src/components/search/SyncStatusPanel.test.tsx
··· 130 130 it("shows progress bars during operations", async () => { 131 131 syncPostsMock.mockImplementation(() => new Promise(() => {})); // Never resolves 132 132 133 - const { container } = render(() => <SyncStatusPanel did="did:plc:test" />); 133 + render(() => <SyncStatusPanel did="did:plc:test" />); 134 134 135 135 const syncButton = await screen.findByRole("button", { name: /sync now/i }); 136 136 fireEvent.click(syncButton); 137 137 138 - // Progress bar should be visible (it's a div with gradient) 139 138 await waitFor(() => { 140 - const progressBars = container.querySelectorAll("[class*=\"bg-linear-to-r\"]"); 141 - expect(progressBars.length).toBeGreaterThan(0); 139 + expect(screen.getByTestId("sync-activity-bar")).toBeInTheDocument(); 142 140 }); 143 141 }); 142 + 143 + it("fades the activity bar out after sync completes", async () => { 144 + vi.useRealTimers(); 145 + syncPostsMock.mockImplementation( 146 + () => 147 + new Promise((resolve) => { 148 + setTimeout(() => { 149 + resolve({ 150 + did: "did:plc:test", 151 + source: "like", 152 + postCount: 150, 153 + lastSyncedAt: "2026-03-29T13:00:00.000Z", 154 + }); 155 + }, 20); 156 + }), 157 + ); 158 + 159 + render(() => <SyncStatusPanel did="did:plc:test" />); 160 + 161 + fireEvent.click(await screen.findByRole("button", { name: /sync now/i })); 162 + 163 + await waitFor(() => { 164 + expect(screen.getByTestId("sync-activity-bar")).toBeInTheDocument(); 165 + }); 166 + 167 + await waitFor(() => { 168 + expect(screen.queryByTestId("sync-activity-bar")).not.toBeInTheDocument(); 169 + }); 170 + }, 10000); 144 171 145 172 it("shows source-specific progress bars", async () => { 146 173 render(() => <SyncStatusPanel did="did:plc:test" />);
+16 -8
src/components/search/SyncStatusPanel.tsx
··· 3 3 import { formatRelativeTime } from "$/lib/feeds"; 4 4 import * as logger from "@tauri-apps/plugin-log"; 5 5 import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; 6 - import { Motion } from "solid-motionone"; 6 + import { Motion, Presence } from "solid-motionone"; 7 7 import { PostCount } from "../shared/PostCount"; 8 8 9 9 function SourceStatusRow( ··· 169 169 </div> 170 170 </div> 171 171 172 - <Show when={isSyncing() || isReindexing()}> 173 - <div class="h-1.5 overflow-hidden rounded-full bg-white/8"> 172 + <Presence> 173 + <Show when={isSyncing() || isReindexing()}> 174 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> 175 + data-testid="sync-activity-bar" 176 + class="h-1.5 overflow-hidden rounded-full bg-white/8" 177 + initial={{ opacity: 0, height: 0 }} 178 + animate={{ opacity: 1, height: "0.375rem" }} 179 + exit={{ opacity: 0, height: 0 }} 180 + transition={{ duration: 0.2 }}> 181 + <Motion.div 182 + class="h-full w-2/3 rounded-full bg-linear-to-r from-primary to-primary-dim" 183 + animate={{ x: ["-40%", "120%"] }} 184 + transition={{ duration: isReindexing() ? 1.8 : 1.1, repeat: Infinity, easing: "linear" }} /> 185 + </Motion.div> 186 + </Show> 187 + </Presence> 180 188 181 189 <div class="grid gap-3"> 182 190 <For each={syncStatus()}>
+5 -4
src/components/settings/SettingsPanel.test.tsx
··· 14 14 const infoMock = vi.hoisted(() => vi.fn()); 15 15 16 16 const DEFAULT_EMBEDDINGS_CONFIG = { 17 - enabled: true, 17 + enabled: false, 18 + preflightSeen: false, 18 19 modelName: "nomic-embed-text-v1.5", 19 20 dimensions: 768, 20 21 modelSizeBytes: 1024 * 1024 * 384, 21 - downloaded: true, 22 + downloaded: false, 22 23 downloadActive: false, 23 24 }; 24 25 ··· 46 47 notificationsDesktop: true, 47 48 notificationsBadge: true, 48 49 notificationsSound: false, 49 - embeddingsEnabled: true, 50 + embeddingsEnabled: false, 50 51 constellationUrl: "https://constellation.microcosm.blue", 51 52 spacedustUrl: "https://spacedust.microcosm.blue", 52 53 spacedustInstant: false, ··· 111 112 expect(await screen.findByText("Data")).toBeInTheDocument(); 112 113 expect(await screen.findByText("Logs")).toBeInTheDocument(); 113 114 expect(await screen.findByText("About")).toBeInTheDocument(); 114 - expect(await screen.findByText(/384 MB on disk/i)).toBeInTheDocument(); 115 + expect(await screen.findAllByText(/384 MB download/i)).toHaveLength(2); 115 116 }); 116 117 117 118 it("displays cache size information", async () => {
+15 -1
src/contexts/app-preferences.tsx
··· 2 2 getEmbeddingsConfig, 3 3 prepareEmbeddingsModel as prepareEmbeddingsModelRequest, 4 4 setEmbeddingsEnabled as setEmbeddingsEnabledRequest, 5 + setEmbeddingsPreflightSeen as setEmbeddingsPreflightSeenRequest, 5 6 } from "$/lib/api/search"; 6 7 import type { EmbeddingsConfig } from "$/lib/api/search"; 7 8 import { getSettings, updateSetting as updateSettingRequest } from "$/lib/api/settings"; ··· 28 29 prepareEmbeddingsModel: () => Promise<void>; 29 30 refresh: () => Promise<void>; 30 31 setEmbeddingsEnabled: (enabled: boolean) => Promise<void>; 32 + setEmbeddingsPreflightSeen: (seen: boolean) => Promise<void>; 31 33 updateSetting: (key: keyof AppSettings, value: string | boolean | number) => Promise<void>; 32 34 }; 33 35 ··· 124 126 } 125 127 } 126 128 129 + async function setEmbeddingsPreflightSeen(seen: boolean) { 130 + try { 131 + await setEmbeddingsPreflightSeenRequest(seen); 132 + setPreferences("embeddingsConfig", (current) => current ? { ...current, preflightSeen: seen } : current); 133 + } catch (error) { 134 + logger.error("failed to set embeddings preflight seen", { 135 + keyValues: { seen: String(seen), error: String(error) }, 136 + }); 137 + } 138 + } 139 + 127 140 async function refresh() { 128 141 await Promise.all([loadSettings(), loadEmbeddingsConfig()]); 129 142 } ··· 137 150 return preferences.embeddingsConfig; 138 151 }, 139 152 get embeddingsEnabled() { 140 - return preferences.embeddingsConfig?.enabled ?? preferences.settings?.embeddingsEnabled ?? true; 153 + return preferences.embeddingsConfig?.enabled ?? preferences.settings?.embeddingsEnabled ?? false; 141 154 }, 142 155 get embeddingsLoading() { 143 156 return preferences.embeddingsLoading; ··· 153 166 prepareEmbeddingsModel, 154 167 refresh, 155 168 setEmbeddingsEnabled, 169 + setEmbeddingsPreflightSeen, 156 170 updateSetting, 157 171 }; 158 172 }
+5
src/lib/api/search.ts
··· 64 64 65 65 export type EmbeddingsConfig = { 66 66 enabled: boolean; 67 + preflightSeen: boolean; 67 68 modelName: string; 68 69 dimensions: number; 69 70 modelSizeBytes?: number | null; ··· 136 137 137 138 export function setEmbeddingsEnabled(enabled: boolean): Promise<void> { 138 139 return invoke("set_embeddings_enabled", { enabled }); 140 + } 141 + 142 + export function setEmbeddingsPreflightSeen(seen: boolean): Promise<void> { 143 + return invoke("set_embeddings_preflight_seen", { seen }); 139 144 } 140 145 141 146 export function getEmbeddingsEnabled(): Promise<boolean> {
+22
src/lib/search-routes.ts
··· 2 2 3 3 export type SearchTab = "posts" | "profiles"; 4 4 export type NetworkSearchSort = "top" | "latest"; 5 + export const SEARCH_ROUTE = "/search"; 6 + export const SEARCH_PREFLIGHT_ROUTE = "/search/preflight"; 5 7 6 8 export type PostSearchFilters = { 7 9 author: string; ··· 77 79 return buildPostSearchRoute(pathname, params.toString(), state); 78 80 } 79 81 82 + export function buildSearchPreflightRoute(next?: string | null) { 83 + const params = new URLSearchParams(); 84 + const normalized = normalizeSearchReturnRoute(next); 85 + if (normalized) { 86 + params.set("next", normalized); 87 + } 88 + 89 + const search = params.toString(); 90 + return search ? `${SEARCH_PREFLIGHT_ROUTE}?${search}` : SEARCH_PREFLIGHT_ROUTE; 91 + } 92 + 80 93 export function decodeHashtagRouteTag(value?: string | null) { 81 94 if (!value) { 82 95 return null; ··· 141 154 q: params.get("q")?.trim() ?? "", 142 155 tab: SEARCH_TABS.has(tab as SearchTab) ? (tab as SearchTab) : SEARCH_ROUTE_DEFAULTS.tab, 143 156 }; 157 + } 158 + 159 + export function normalizeSearchReturnRoute(value?: string | null) { 160 + const trimmed = value?.trim() ?? ""; 161 + if (!trimmed.startsWith(SEARCH_ROUTE) || trimmed.startsWith(SEARCH_PREFLIGHT_ROUTE)) { 162 + return SEARCH_ROUTE; 163 + } 164 + 165 + return trimmed; 144 166 } 145 167 146 168 export function toLocalDayStartIso(value: string) {
+45 -1
src/router.test.tsx
··· 17 17 "$/components/search/HashtagPanel", 18 18 () => ({ HashtagPanel: () => <div data-testid="hashtag-view">hashtag</div> }), 19 19 ); 20 + vi.mock( 21 + "$/components/search/SearchPanel", 22 + () => ({ SearchPanel: () => <div data-testid="search-view">search</div> }), 23 + ); 24 + vi.mock( 25 + "$/components/search/SearchPreflightPanel", 26 + () => ({ SearchPreflightPanel: () => <div data-testid="search-preflight-view">preflight</div> }), 27 + ); 20 28 21 29 const Shell: Component<ParentProps<{ fullWidth?: boolean }>> = (props) => ( 22 30 <div data-testid="shell" data-full-width={props.fullWidth ? "true" : "false"}>{props.children}</div> 23 31 ); 24 32 25 - function renderRouter(hash: string) { 33 + function renderRouter(hash: string, options: { preferences?: Record<string, unknown> } = {}) { 26 34 globalThis.location.hash = hash; 27 35 const renderComposer = vi.fn(() => <div data-testid="composer-view">composer</div>); 28 36 const renderNotifications = vi.fn(() => <div data-testid="notifications-view">notifications</div>); ··· 39 47 40 48 render(() => ( 41 49 <AppTestProviders 50 + preferences={options.preferences} 42 51 session={{ 43 52 activeDid: "did:plc:alice", 44 53 activeHandle: "alice.test", ··· 163 172 await screen.findByTestId("hashtag-view"); 164 173 165 174 expect(screen.getByText("hashtag")).toBeInTheDocument(); 175 + }); 176 + 177 + it("redirects first search visits to the embeddings preflight", async () => { 178 + renderRouter("#/search"); 179 + 180 + await screen.findByTestId("search-preflight-view"); 181 + 182 + expect(screen.getByText("preflight")).toBeInTheDocument(); 183 + }); 184 + 185 + it("renders the search route once the preflight was already seen", async () => { 186 + renderRouter("#/search", { 187 + preferences: { 188 + embeddingsConfig: { 189 + enabled: false, 190 + preflightSeen: true, 191 + modelName: "nomic-embed-text-v1.5", 192 + dimensions: 768, 193 + downloaded: false, 194 + downloadActive: false, 195 + }, 196 + }, 197 + }); 198 + 199 + await screen.findByTestId("search-view"); 200 + 201 + expect(screen.getByText("search")).toBeInTheDocument(); 202 + }); 203 + 204 + it("does not redirect profile tab search visits to the embeddings preflight", async () => { 205 + renderRouter("#/search?tab=profiles"); 206 + 207 + await screen.findByTestId("search-view"); 208 + 209 + expect(screen.getByText("search")).toBeInTheDocument(); 166 210 }); 167 211 });
+32 -3
src/router.tsx
··· 1 + import { useAppPreferences } from "$/contexts/app-preferences"; 1 2 import { useAppSession } from "$/contexts/app-session"; 2 3 import { useAppShellUi } from "$/contexts/app-shell-ui"; 3 4 import { HashRouter, Navigate, Route, useLocation, useParams } from "@solidjs/router"; ··· 8 9 import { ExplorerPanel } from "./components/explorer/ExplorerPanel"; 9 10 import { SavedPostsPanel } from "./components/saved/SavedPostsPanel"; 10 11 import { HashtagPanel } from "./components/search/HashtagPanel"; 12 + import { SearchPreflightPanel } from "./components/search/SearchPreflightPanel"; 11 13 import { SearchPanel } from "./components/search/SearchPanel"; 12 14 import { SettingsPanel } from "./components/settings/SettingsPanel"; 13 15 import { decodeMessagesRouteMemberDid } from "./lib/conversations"; 14 16 import { TIMELINE_ROUTE } from "./lib/feeds"; 15 17 import { decodeProfileRouteActor } from "./lib/profile"; 16 - import { decodeHashtagRouteTag } from "./lib/search-routes"; 18 + import { buildSearchPreflightRoute, decodeHashtagRouteTag, parseSearchRouteState } from "./lib/search-routes"; 17 19 18 20 type TMessagesRouteProps = { memberDid: string | null }; 19 21 type TProfileRouteProps = { actor: string | null }; ··· 68 70 69 71 const TimelineRoute = () => <ProtectedRouteView>{props.renderTimeline()}</ProtectedRouteView>; 70 72 71 - const SearchRoute = () => ( 73 + const SearchRoute = () => <ProtectedRouteView><SearchRouteGate /></ProtectedRouteView>; 74 + 75 + const SearchPreflightRoute = () => ( 72 76 <ProtectedRouteView> 73 - <SearchPanel /> 77 + <SearchPreflightPanel /> 74 78 </ProtectedRouteView> 75 79 ); 76 80 ··· 161 165 <Route path="/profile" component={ProfileRoute} /> 162 166 <Route path="/profile/:actor" component={ActorProfileRoute} /> 163 167 <Route path="/composer" component={ComposerRoute} /> 168 + <Route path="/search/preflight" component={SearchPreflightRoute} /> 164 169 <Route path="/search" component={SearchRoute} /> 165 170 <Route path="/hashtag/:hashtag" component={HashtagRoute} /> 166 171 <Route path="/saved" component={SavedPostsRoute} /> ··· 172 177 <Route path="/settings" component={SettingsRoute} /> 173 178 <Route path="*404" component={NotFoundRoute} /> 174 179 </HashRouter> 180 + ); 181 + } 182 + 183 + function SearchRouteGate() { 184 + const preferences = useAppPreferences(); 185 + const location = useLocation(); 186 + const routeState = () => parseSearchRouteState(location.search); 187 + const nextRoute = () => `${location.pathname}${location.search}`; 188 + const showLoading = () => preferences.embeddingsLoading && !preferences.embeddingsConfig; 189 + const shouldRedirect = () => { 190 + const config = preferences.embeddingsConfig; 191 + if (!config || routeState().tab !== "posts") { 192 + return false; 193 + } 194 + 195 + return !config.enabled && !config.preflightSeen; 196 + }; 197 + 198 + return ( 199 + <Show when={!showLoading()} fallback={<RouteLoadingState />}> 200 + <Show when={!shouldRedirect()} fallback={<Navigate href={buildSearchPreflightRoute(nextRoute())} />}> 201 + <SearchPanel /> 202 + </Show> 203 + </Show> 175 204 ); 176 205 } 177 206
+5 -3
src/test/providers.tsx
··· 22 22 notificationsDesktop: true, 23 23 notificationsBadge: true, 24 24 notificationsSound: false, 25 - embeddingsEnabled: true, 25 + embeddingsEnabled: false, 26 26 constellationUrl: "https://constellation.microcosm.blue", 27 27 spacedustUrl: "https://spacedust.microcosm.blue", 28 28 spacedustInstant: false, ··· 31 31 }; 32 32 33 33 const DEFAULT_EMBEDDINGS_CONFIG = { 34 - enabled: true, 34 + enabled: false, 35 + preflightSeen: false, 35 36 modelName: "nomic-embed-text-v1.5", 36 37 dimensions: 768, 37 - downloaded: true, 38 + downloaded: false, 38 39 downloadActive: false, 39 40 }; 40 41 ··· 106 107 prepareEmbeddingsModel: overrides.prepareEmbeddingsModel ?? (async () => {}), 107 108 refresh: overrides.refresh ?? (async () => {}), 108 109 setEmbeddingsEnabled: overrides.setEmbeddingsEnabled ?? (async () => {}), 110 + setEmbeddingsPreflightSeen: overrides.setEmbeddingsPreflightSeen ?? (async () => {}), 109 111 updateSetting: overrides.updateSetting ?? (async () => {}), 110 112 }; 111 113 }