//! Desktop-owned orchestration: managed model setup and persistent hybrid queries. use crate::{measured_outcome, model_download, AppState, Filters, MeasuredFailure}; use searchi_core::metrics::{MetricsMode, SearchResponse, TraceCollector}; use searchi_core::SearchResult; use searchi_service::{EmbeddingReport, HybridSearch}; use serde::Serialize; use std::{ path::PathBuf, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Mutex, }, }; use tauri::State; pub struct SemanticState { pub model_directory: PathBuf, pub worker: PathBuf, verified: AtomicBool, downloading: AtomicBool, embedding: AtomicBool, querying: AtomicBool, warmed: AtomicBool, warming: AtomicBool, downloaded_bytes: AtomicU64, error: Mutex>, service: Mutex>, media_tools: Option, media_setup_error: Option, } impl SemanticState { pub fn new( model_directory: PathBuf, worker: PathBuf, media_tools: Result, ) -> Self { let (media_tools, media_setup_error) = match media_tools { Ok(tools) => (Some(tools), None), Err(error) => (None, Some(error)), }; Self { model_directory, worker, verified: AtomicBool::new(false), downloading: AtomicBool::new(false), embedding: AtomicBool::new(false), querying: AtomicBool::new(false), warmed: AtomicBool::new(false), warming: AtomicBool::new(false), downloaded_bytes: AtomicU64::new(0), error: Mutex::new(None), service: Mutex::new(None), media_tools, media_setup_error, } } pub fn reset_session(&self) -> Result<(), String> { *self .service .try_lock() .map_err(|_| "Wait for the current semantic query before changing resources.")? = None; self.warmed.store(false, Ordering::Release); Ok(()) } fn prepare_query_session(&self, database: PathBuf, threads: usize) -> Result<(), String> { self.warming.store(true, Ordering::Release); let _guard = WorkGuard(&self.warming); if !self.worker.is_file() { return Err( "The installed embedding worker is missing. Repair or reinstall Searchi.".into(), ); } let mut service = HybridSearch::open(database, &self.model_directory, &self.worker, threads) .map_err(|error| error.to_string())?; service.warmup().map_err(|error| error.to_string())?; *self .service .lock() .map_err(|_| "Semantic session is unavailable.")? = Some(service); self.warmed.store(true, Ordering::Release); Ok(()) } /// First-launch setup is authorized product behavior; inference remains offline. pub fn begin_automatic_setup(self: &std::sync::Arc, database: PathBuf, threads: usize) { if self .downloading .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return; } let semantic = self.clone(); tauri::async_runtime::spawn_blocking(move || { let _guard = WorkGuard(&semantic.downloading); semantic.record_error(None); let result = model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes); semantic.verified.store(result.is_ok(), Ordering::Release); let result = result.and_then(|_| semantic.prepare_query_session(database, threads)); semantic.record_error(result.err()); }); } fn record_error(&self, value: Option) { if let Ok(mut error) = self.error.lock() { *error = value; } } } #[derive(Serialize)] pub struct SemanticStatus { model: &'static str, ready: bool, model_directory: String, worker_ready: bool, downloading: bool, embedding: bool, device_policy: &'static str, error: Option, downloaded_bytes: u64, total_bytes: u64, generation: Option, chunks: usize, stale: bool, pending: usize, warming: bool, worker_info: Option, media_tools_ready: bool, media_setup_error: Option, media_failures: Vec, } struct WorkGuard<'a>(&'a AtomicBool); impl Drop for WorkGuard<'_> { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } #[tauri::command] pub fn semantic_status(state: State<'_, AppState>) -> Result { let semantic = &state.semantic; let worker_ready = semantic.worker.is_file(); let index = HybridSearch::embedding_status(&state.database).map_err(|error| error.to_string())?; Ok(SemanticStatus { model: "Google EmbeddingGemma 2", ready: worker_ready && semantic.warmed.load(Ordering::Acquire), model_directory: semantic.model_directory.to_string_lossy().into_owned(), worker_ready, downloading: semantic.downloading.load(Ordering::Acquire), embedding: semantic.embedding.load(Ordering::Acquire), device_policy: if std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1") { "cpu" } else { "auto" }, error: semantic.error.lock().ok().and_then(|value| value.clone()), downloaded_bytes: semantic.downloaded_bytes.load(Ordering::Acquire), total_bytes: model_download::TOTAL_BYTES, generation: index.generation, chunks: index.chunks, stale: index.stale, pending: index.pending, warming: semantic.warming.load(Ordering::Acquire), worker_info: semantic .service .try_lock() .ok() .and_then(|service| service.as_ref().and_then(HybridSearch::worker_info)), media_tools_ready: semantic.media_tools.is_some(), media_setup_error: semantic.media_setup_error.clone(), media_failures: index.failures, }) } #[tauri::command] pub async fn setup_model(state: State<'_, AppState>) -> Result<(), String> { let semantic = state.semantic.clone(); let database = state.database.clone(); let threads = state .settings .lock() .map_err(|_| "Settings are unavailable.")? .value .cpu_threads; if semantic .downloading .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return Err("Google model setup is already running.".into()); } tauri::async_runtime::spawn_blocking(move || { let _guard = WorkGuard(&semantic.downloading); semantic.record_error(None); let result = model_download::provision(&semantic.model_directory, &semantic.downloaded_bytes); semantic.verified.store(result.is_ok(), Ordering::Release); let result = result.and_then(|_| semantic.prepare_query_session(database, threads)); semantic.record_error(result.as_ref().err().cloned()); result }) .await .map_err(|error| error.to_string())? } #[tauri::command] pub async fn index_embeddings(state: State<'_, AppState>) -> Result { let semantic = state.semantic.clone(); if !semantic.verified.load(Ordering::Acquire) || !semantic.worker.is_file() { return Err("Set up the Google model and native worker first.".into()); } let threads = state .settings .lock() .map_err(|_| "Settings are unavailable.")? .value .cpu_threads; if semantic .embedding .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return Err("Semantic indexing is already running.".into()); } let database = state.database.clone(); tauri::async_runtime::spawn_blocking(move || { let _guard = WorkGuard(&semantic.embedding); semantic.record_error(None); // Indexing uses its own isolated worker; interactive queries keep their own session. let result = HybridSearch::open( &database, &semantic.model_directory, &semantic.worker, threads, ) .and_then(|mut service| { let tools = semantic.media_tools.as_ref().ok_or_else(|| { anyhow::anyhow!("The media decoder is missing from this installation. Repair or reinstall searchi.") })?; service.index_embeddings_with_media(tools, &searchi_media::MediaLimits::default()) }) .map_err(|error| error.to_string()); // Prepare the newly activated graph before advertising semantic readiness. // Keyword search stays available while the cached query session catches up. if result.is_ok() { semantic.warmed.store(false, Ordering::Release); semantic.warming.store(true, Ordering::Release); let _warming_guard = WorkGuard(&semantic.warming); let preparation = (|| { let mut cached = semantic .service .lock() .map_err(|_| "Semantic session is unavailable.".to_string())?; let service = cached .as_mut() .ok_or_else(|| "Semantic query session has not been prepared.".to_string())?; service.warmup().map_err(|error| error.to_string()) })(); semantic.warmed.store(preparation.is_ok(), Ordering::Release); if let Err(error) = preparation { semantic.record_error(Some(format!( "The index was saved, but search preparation failed: {error}" ))); return result; } } semantic.record_error(result.as_ref().err().cloned()); result }) .await .map_err(|error| error.to_string())? } #[tauri::command] pub async fn hybrid_search( query: String, filters: Option, state: State<'_, AppState>, ) -> Result, String> { let semantic = state.semantic.clone(); if !semantic.warmed.load(Ordering::Acquire) { return Err( "Semantic search is preparing in the background; keyword results are available.".into(), ); } let database = state.database.clone(); let threads = state .settings .lock() .map_err(|_| "Settings are unavailable.")? .value .cpu_threads; if semantic .querying .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return Err("Semantic worker is busy; keyword results remain available.".into()); } tauri::async_runtime::spawn_blocking(move || { let _guard = WorkGuard(&semantic.querying); let mut cached = semantic .service .lock() .map_err(|_| "Semantic session is unavailable.")?; if cached.is_none() { *cached = Some( HybridSearch::open( database, &semantic.model_directory, &semantic.worker, threads, ) .map_err(|error| error.to_string())?, ); } cached .as_mut() .ok_or("Semantic session is unavailable.")? .search(&query, 50, &filters.unwrap_or_default().into()) .map_err(|error| error.to_string()) }) .await .map_err(|error| error.to_string())? } #[tauri::command] pub async fn hybrid_search_measured( query: String, filters: Option, debug_performance: bool, state: State<'_, AppState>, ) -> Result, MeasuredFailure> { let started = std::time::Instant::now(); let mut trace = TraceCollector::new(if debug_performance { MetricsMode::Debug } else { MetricsMode::Basic }); let semantic = state.semantic.clone(); if !semantic.warmed.load(Ordering::Acquire) { return Err(MeasuredFailure::new( "Semantic search is preparing in the background; keyword results are available.", trace, )); } let threads = trace.timed("desktop.settings_lock", None, |_, _| { state .settings .lock() .map(|settings| settings.value.cpu_threads) .map_err(|_| "Settings are unavailable.") }); let threads = match threads { Ok(threads) => threads, Err(error) => return Err(MeasuredFailure::new(error, trace)), }; if semantic .querying .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_err() { return Err(MeasuredFailure::new( "Semantic worker is busy; keyword results remain available.", trace, )); } let database = state.database.clone(); let queue = trace.begin("desktop.worker_queue", None); tauri::async_runtime::spawn_blocking(move || { let _guard = WorkGuard(&semantic.querying); trace.end(queue, Default::default()); let result = (|| { let mut cached = trace.timed("desktop.semantic_lock", None, |_, _| { semantic .service .lock() .map_err(|_| "Semantic session is unavailable.") })?; if cached.is_none() { *cached = Some(trace.timed("desktop.session_initialize", None, |_, _| { HybridSearch::open( database, &semantic.model_directory, &semantic.worker, threads, ) .map_err(|error| error.to_string()) })?); } cached .as_mut() .ok_or("Semantic session is unavailable.")? .search_traced( &query, 50, &filters.unwrap_or_default().into(), &mut trace, None, ) .map_err(|error| error.to_string()) })(); measured_outcome(result, trace) }) .await .map_err(|error| MeasuredFailure::task(error.to_string(), started))? }