51e4c2c1f7
- add Candle + HF Hub CLIP image embedding pipeline with background embedding worker - write image embeddings into sqlite-vec and expose similar-image lookup through a new backend command - surface embedding progress and recovery in the UI, including retries for failed embeddings - improve gallery/lightbox embedding UX and make similar-image actions directly accessible Refs: #3, #4
191 lines
5.2 KiB
Rust
191 lines
5.2 KiB
Rust
use crate::db::{self, DbPool, Folder, FolderJobProgress, ImageRecord};
|
|
use crate::indexer;
|
|
use crate::vector;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::PathBuf;
|
|
use tauri::{AppHandle, State};
|
|
|
|
pub type DbState = DbPool;
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ImagesPage {
|
|
pub images: Vec<ImageRecord>,
|
|
pub total: i64,
|
|
pub offset: i64,
|
|
pub limit: i64,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct GetImagesParams {
|
|
pub folder_id: Option<i64>,
|
|
pub search: Option<String>,
|
|
pub media_kind: Option<String>,
|
|
pub favorites_only: Option<bool>,
|
|
pub sort: Option<String>,
|
|
pub offset: Option<i64>,
|
|
pub limit: Option<i64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct UpdateImageDetailsParams {
|
|
pub image_id: i64,
|
|
pub favorite: Option<bool>,
|
|
pub rating: Option<i64>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct FindSimilarImagesParams {
|
|
pub image_id: i64,
|
|
pub limit: Option<usize>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct RetryFailedEmbeddingsParams {
|
|
pub folder_id: i64,
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn add_folder(
|
|
app: AppHandle,
|
|
db: State<'_, DbState>,
|
|
path: String,
|
|
) -> Result<Folder, String> {
|
|
let folder_path = PathBuf::from(&path);
|
|
|
|
if !folder_path.exists() || !folder_path.is_dir() {
|
|
return Err("Path is not a valid directory".into());
|
|
}
|
|
|
|
let name = folder_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().to_string())
|
|
.unwrap_or_else(|| path.clone());
|
|
|
|
let folder_id = {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::insert_folder(&conn, &path, &name).map_err(|e| e.to_string())?
|
|
};
|
|
|
|
let folders = {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::get_folders(&conn).map_err(|e| e.to_string())?
|
|
};
|
|
|
|
let folder = folders
|
|
.into_iter()
|
|
.find(|f| f.id == folder_id)
|
|
.ok_or("Folder not found after insert")?;
|
|
|
|
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
|
|
|
|
Ok(folder)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_folders(db: State<'_, DbState>) -> Result<Vec<Folder>, String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::get_folders(&conn).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_background_job_progress(
|
|
db: State<'_, DbState>,
|
|
) -> Result<Vec<FolderJobProgress>, String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::get_all_folder_job_progress(&conn).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn remove_folder(db: State<'_, DbState>, folder_id: i64) -> Result<(), String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn get_images(
|
|
db: State<'_, DbState>,
|
|
params: GetImagesParams,
|
|
) -> Result<ImagesPage, String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
|
|
let sort = params.sort.as_deref().unwrap_or("date_desc");
|
|
let offset = params.offset.unwrap_or(0);
|
|
let limit = params.limit.unwrap_or(100);
|
|
let search = params.search.as_deref();
|
|
let media_kind = params.media_kind.as_deref();
|
|
let favorites_only = params.favorites_only.unwrap_or(false);
|
|
|
|
let total = db::count_images(&conn, params.folder_id, search, media_kind, favorites_only)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let images = db::get_images(
|
|
&conn,
|
|
params.folder_id,
|
|
search,
|
|
media_kind,
|
|
favorites_only,
|
|
sort,
|
|
offset,
|
|
limit,
|
|
)
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(ImagesPage {
|
|
images,
|
|
total,
|
|
offset,
|
|
limit,
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn update_image_details(
|
|
db: State<'_, DbState>,
|
|
params: UpdateImageDetailsParams,
|
|
) -> Result<ImageRecord, String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::update_image_details(&conn, params.image_id, params.favorite, params.rating)
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn reindex_folder(
|
|
app: AppHandle,
|
|
db: State<'_, DbState>,
|
|
folder_id: i64,
|
|
) -> Result<(), String> {
|
|
let folder_path = {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
let folders = db::get_folders(&conn).map_err(|e| e.to_string())?;
|
|
folders
|
|
.into_iter()
|
|
.find(|f| f.id == folder_id)
|
|
.map(|f| PathBuf::from(f.path))
|
|
.ok_or("Folder not found")?
|
|
};
|
|
|
|
indexer::index_folder(app, db.inner().clone(), folder_id, folder_path);
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn find_similar_images(
|
|
db: State<'_, DbState>,
|
|
params: FindSimilarImagesParams,
|
|
) -> Result<Vec<ImageRecord>, String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
let limit = params.limit.unwrap_or(32);
|
|
let image_ids = vector::find_similar_image_ids(&conn, params.image_id, limit)
|
|
.map_err(|e| e.to_string())?;
|
|
db::get_images_by_ids(&conn, &image_ids).map_err(|e| e.to_string())
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn retry_failed_embeddings(
|
|
db: State<'_, DbState>,
|
|
params: RetryFailedEmbeddingsParams,
|
|
) -> Result<usize, String> {
|
|
let conn = db.get().map_err(|e| e.to_string())?;
|
|
db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string())
|
|
}
|