From 52d54d2404dc684ced8f8757f9ee5b1e12f33d9d Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 09:40:12 +0100 Subject: [PATCH 01/13] feat(tagger): add JoyTag provider behind a tagger-model abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a selectable tagging model so Phokus isn't locked to the anime-leaning WD tagger. JoyTag uses the Danbooru schema but generalizes to photographic content and is strong on NSFW concepts — a better fit for a photo manager while keeping the explicitness range. - `TaggerModel` enum (wd | joytag) persisted as a `tagger_model` setting; `model_dir`, status, and download now follow the active model (per-model repo/dir/file list). WD stays the default. - `Tagger` trait implemented by `WdTagger` and the new `JoyTagger`; `create_active_tagger` builds the selected one. The worker holds `Box` and rebuilds on model change (TAGGER_SESSION_DIRTY). - Shared `assemble_batch` skeleton (pack -> one forward pass -> per-image fallback, results in input order); both providers and the shared decode/pad/resize are de-duped onto common helpers. - JoyTag specifics: NCHW + RGB + CLIP-normalized input (vs WD's NHWC/BGR/raw), flat top_tags.txt labels, logits -> sigmoid -> threshold (default 0.4). It has no native rating, so the explicitness rating is derived from its NSFW tags. - Tags are attributed to the model that produced them (ai_tagger_model), via Tagger::model_name, instead of a hardcoded WD constant. - New get/set_tagger_model commands. Backend only; the Settings model picker and end-to-end testing against the JoyTag model files come next. --- src-tauri/src/commands.rs | 22 +- src-tauri/src/indexer.rs | 14 +- src-tauri/src/lib.rs | 2 + src-tauri/src/tagger.rs | 646 +++++++++++++++++++++++++++++++------- 4 files changed, 557 insertions(+), 127 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b591786..48595ca 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -8,7 +8,7 @@ use crate::db::{ use crate::embedder; use crate::hnsw_index; use crate::indexer::{self, WatcherHandle}; -use crate::tagger::{self, TaggerAcceleration, TaggerModelStatus, TaggerRuntimeProbe}; +use crate::tagger::{self, TaggerAcceleration, TaggerModel, TaggerModelStatus, TaggerRuntimeProbe}; use crate::vector; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -1970,6 +1970,11 @@ pub struct SetTaggerAccelerationParams { pub acceleration: TaggerAcceleration, } +#[derive(Deserialize)] +pub struct SetTaggerModelParams { + pub model: TaggerModel, +} + #[derive(Deserialize)] pub struct SetTaggerThresholdParams { pub threshold: f32, @@ -2030,6 +2035,21 @@ pub async fn set_tagger_acceleration( tagger::set_tagger_acceleration(&app_dir, params.acceleration).map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn get_tagger_model(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(tagger::tagger_model(&app_dir)) +} + +#[tauri::command] +pub async fn set_tagger_model( + app: AppHandle, + params: SetTaggerModelParams, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + tagger::set_tagger_model(&app_dir, params.model).map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn probe_tagger_runtime(app: AppHandle) -> Result { let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index ba09c05..28dab25 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -3,7 +3,7 @@ use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, Inde use crate::embedder::{embedding_source_path, ClipImageEmbedder}; use crate::media::{probe_video_metadata, MediaTools}; use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile}; -use crate::tagger::{self, WdTagger}; +use crate::tagger::{self, Tagger}; use crate::thumbnail; use crate::vector; use anyhow::Result; @@ -330,7 +330,7 @@ pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) { std::thread::spawn(move || { - let mut tagger_instance: Option = None; + let mut tagger_instance: Option> = None; log::info!("Tagging worker started."); loop { // If the acceleration setting changed, drop the cached session so @@ -1236,7 +1236,7 @@ fn process_tagging_batch( app: &AppHandle, pool: &DbPool, app_data_dir: &Path, - tagger_instance: &mut Option, + tagger_instance: &mut Option>, ) -> Result { if !tagger::tagger_model_status(app_data_dir).ready { return Ok(false); @@ -1264,7 +1264,7 @@ fn process_tagging_batch( } if tagger_instance.is_none() { - match WdTagger::new(app_data_dir) { + match tagger::create_active_tagger(app_data_dir) { Ok(model) => *tagger_instance = Some(model), Err(error) => { with_db_write_lock(|| { @@ -1323,6 +1323,10 @@ fn process_tagging_batch( } let infer_elapsed = infer_started_at.elapsed(); + // Attribute the tags to the model that actually produced them, not a + // hardcoded one (WD vs JoyTag are both possible). + let tagger_model_name = tagger_ref.model_name(); + let tag_results = jobs.iter().cloned().zip(outputs).collect::>(); let write_started_at = Instant::now(); @@ -1355,7 +1359,7 @@ fn process_tagging_batch( job.image_id, &tag_pairs, &output.rating, - tagger::WD_TAGGER_MODEL_NAME, + tagger_model_name, )?; } Err(error) => { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c5fc8d..18a7eac 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -202,6 +202,8 @@ pub fn run() { commands::get_tagger_model_status, commands::get_tagger_acceleration, commands::set_tagger_acceleration, + commands::get_tagger_model, + commands::set_tagger_model, commands::probe_tagger_runtime, commands::get_tagger_threshold, commands::set_tagger_threshold, diff --git a/src-tauri/src/tagger.rs b/src-tauri/src/tagger.rs index 82518c7..45e5150 100644 --- a/src-tauri/src/tagger.rs +++ b/src-tauri/src/tagger.rs @@ -16,15 +16,27 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3"; const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt"; const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt"; const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt"; +const TAGGER_MODEL_FILE: &str = "settings/tagger_model.txt"; -// Files required on disk before the tagger can run. The ONNX runtime DLLs -// are shared with the captioner and live in the same `onnxruntime/` directory. -const TAGGER_REQUIRED_FILES: &[&str] = &[ +pub const JOYTAG_MODEL_ID: &str = "fancyfeast/joytag"; +pub const JOYTAG_MODEL_NAME: &str = "joytag"; + +// JoyTag preprocessing differs from the WD tagger: it expects RGB (not BGR), +// CLIP-style mean/std normalization on [0,1] values (not raw [0,255]), and an +// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants. +const JOYTAG_MEAN: [f32; 3] = [0.481_454_66, 0.457_827_5, 0.408_210_73]; +const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_58, 0.275_777_11]; +// JoyTag's recommended detection threshold (used as the default; the user's +// tagger_threshold setting still overrides it). +const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4; + +// Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and +// are reused by the tagger). The per-model weight + label files are listed by +// `TaggerModel::download_files`. +const TAGGER_RUNTIME_DLLS: &[&str] = &[ "onnxruntime/onnxruntime.dll", "onnxruntime/onnxruntime_providers_shared.dll", "onnxruntime/DirectML.dll", - "model.onnx", - "selected_tags.csv", ]; // Tags in these Danbooru categories are kept in the output. @@ -78,6 +90,61 @@ impl TaggerAcceleration { } } +/// Which tagging model is active. Both produce a `TaggerOutput` (tags + an +/// explicitness rating); they differ in vocabulary, preprocessing, and how the +/// rating is derived. WD is Danbooru-trained (anime-leaning); JoyTag uses the +/// Danbooru schema but generalizes to photographic content and is stronger on +/// NSFW concepts. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum TaggerModel { + #[default] + Wd, + JoyTag, +} + +impl TaggerModel { + fn as_str(self) -> &'static str { + match self { + Self::Wd => "wd", + Self::JoyTag => "joytag", + } + } + + /// Hugging Face repo the model files are fetched from. + fn repo_id(self) -> &'static str { + match self { + Self::Wd => WD_TAGGER_MODEL_ID, + Self::JoyTag => JOYTAG_MODEL_ID, + } + } + + /// Stable display/identifier name. + fn model_name(self) -> &'static str { + match self { + Self::Wd => WD_TAGGER_MODEL_NAME, + Self::JoyTag => JOYTAG_MODEL_NAME, + } + } + + /// Subdirectory under `models/` where this model's files live. + fn dir_name(self) -> &'static str { + match self { + Self::Wd => "wd-swinv2-tagger-v3", + Self::JoyTag => "joytag", + } + } + + /// Files fetched from `repo_id` (the shared ONNX Runtime DLLs are handled + /// separately). `model.onnx` is the weights; the second is the label list. + fn download_files(self) -> &'static [&'static str] { + match self { + Self::Wd => &["model.onnx", "selected_tags.csv"], + Self::JoyTag => &["model.onnx", "top_tags.txt"], + } + } +} + // --------------------------------------------------------------------------- // Status / probe types exposed to the frontend // --------------------------------------------------------------------------- @@ -152,14 +219,41 @@ struct TagEntry { // Path helpers // --------------------------------------------------------------------------- +/// Directory of the *active* tagger model's files (driven by the +/// `tagger_model` setting), e.g. `…/models/wd-swinv2-tagger-v3`. pub fn model_dir(app_data_dir: &Path) -> PathBuf { - app_data_dir.join("models").join("wd-swinv2-tagger-v3") + app_data_dir + .join("models") + .join(tagger_model(app_data_dir).dir_name()) } // --------------------------------------------------------------------------- // Settings persistence // --------------------------------------------------------------------------- +pub fn tagger_model(app_data_dir: &Path) -> TaggerModel { + let path = app_data_dir.join(TAGGER_MODEL_FILE); + let Ok(value) = std::fs::read_to_string(path) else { + return TaggerModel::default(); + }; + match value.trim().to_ascii_lowercase().as_str() { + "joytag" => TaggerModel::JoyTag, + _ => TaggerModel::Wd, + } +} + +pub fn set_tagger_model(app_data_dir: &Path, model: TaggerModel) -> Result { + let path = app_data_dir.join(TAGGER_MODEL_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, model.as_str())?; + // Switching models means the cached session is for the wrong model; the + // worker drops and rebuilds it on the next batch (same flag as an EP change). + TAGGER_SESSION_DIRTY.store(true, Ordering::Relaxed); + Ok(model) +} + pub fn tagger_acceleration(app_data_dir: &Path) -> TaggerAcceleration { let path = app_data_dir.join(TAGGER_ACCELERATION_FILE); let Ok(value) = std::fs::read_to_string(path) else { @@ -231,25 +325,27 @@ pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result TaggerModelStatus { + let model = tagger_model(app_data_dir); let local_dir = model_dir(app_data_dir); // The ONNX runtime DLLs live in the caption model dir; reuse them. let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft"); - let missing_files = TAGGER_REQUIRED_FILES + + let mut missing_files: Vec = TAGGER_RUNTIME_DLLS .iter() - .filter(|file| { - let path = if file.starts_with("onnxruntime/") { - caption_model_dir.join(file) - } else { - local_dir.join(file) - }; - !path.exists() - }) + .filter(|file| !caption_model_dir.join(file).exists()) .map(|file| (*file).to_string()) - .collect::>(); + .collect(); + missing_files.extend( + model + .download_files() + .iter() + .filter(|file| !local_dir.join(file).exists()) + .map(|file| (*file).to_string()), + ); TaggerModelStatus { - model_id: WD_TAGGER_MODEL_ID, - model_name: WD_TAGGER_MODEL_NAME, + model_id: model.repo_id(), + model_name: model.model_name(), local_dir: local_dir.to_string_lossy().to_string(), ready: missing_files.is_empty(), missing_files, @@ -260,19 +356,20 @@ pub fn prepare_tagger_model_with_progress( app_data_dir: &Path, emit_progress: impl Fn(TaggerModelProgress), ) -> Result { + let model = tagger_model(app_data_dir); let local_dir = model_dir(app_data_dir); std::fs::create_dir_all(&local_dir)?; // The tagger shares the ONNX runtime DLLs with the captioner; download // them here so the tagger works even on a clean install where the caption // model has never been fetched (ensure_onnx_runtime only initializes). - const DOWNLOAD_FILES: &[&str] = &["model.onnx", "selected_tags.csv"]; + let download_files = model.download_files(); let caption_model_dir = crate::captioner::model_dir(app_data_dir); std::fs::create_dir_all(&caption_model_dir)?; // Unified step count across DLLs and tagger files so the bar is coherent. let dll_count = crate::captioner::missing_onnx_runtime_count(&caption_model_dir); - let model_pending = DOWNLOAD_FILES + let model_pending = download_files .iter() .filter(|file| !local_dir.join(file).exists()) .count(); @@ -318,9 +415,9 @@ pub fn prepare_tagger_model_with_progress( // (timeout + resume), rather than hf-hub's download_with_progress, whose // agent has no read timeout and would hang on a stalled connection. let api = Api::new()?; - let repo = api.repo(Repo::new(WD_TAGGER_MODEL_ID.to_string(), RepoType::Model)); + let repo = api.repo(Repo::new(model.repo_id().to_string(), RepoType::Model)); - for file in DOWNLOAD_FILES { + for file in download_files { let destination = local_dir.join(file); if destination.exists() { continue; @@ -424,11 +521,96 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result { } // --------------------------------------------------------------------------- -// Top-level inference entry point +// Tagger trait + shared batch skeleton // --------------------------------------------------------------------------- +/// A loaded tagging model. Implementations differ in vocabulary, preprocessing, +/// and how the explicitness rating is derived, but all turn a batch of image +/// paths into one `TaggerOutput` per path (in order), with per-image failures +/// reflected in the individual `Result`s. Built for the active model by +/// [`create_active_tagger`]. +pub trait Tagger { + fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec>; + + /// Stable name of this model, written to the DB as `ai_tagger_model` so + /// tags can be attributed to (and re-tagged across) models. + fn model_name(&self) -> &'static str; +} + +/// Build the tagger for the currently-selected model. +pub fn create_active_tagger(app_data_dir: &Path) -> Result> { + match tagger_model(app_data_dir) { + TaggerModel::Wd => Ok(Box::new(WdTagger::new(app_data_dir)?)), + TaggerModel::JoyTag => Ok(Box::new(JoyTagger::new(app_data_dir)?)), + } +} + +/// Shared batch skeleton: pack the successfully-preprocessed images into one +/// contiguous buffer, run a single batched forward pass via `infer`, and fall +/// back to per-image inference if the batch fails (e.g. a model pinned to +/// batch=1). Returns one result per input slot, in order — a decode failure +/// stays attached to its own slot. `infer(pixels, count)` runs `count` +/// contiguous images (`count * stride` floats) and returns `count` outputs. +fn assemble_batch( + preprocessed: Vec>>, + stride: usize, + model_label: &str, + mut infer: impl FnMut(&[f32], usize) -> Result>, +) -> Vec> { + let mut batch_slots: Vec = Vec::new(); + let mut batch_pixels: Vec = Vec::with_capacity(preprocessed.len() * stride); + for (i, result) in preprocessed.iter().enumerate() { + if let Ok(pixels) = result { + batch_slots.push(i); + batch_pixels.extend_from_slice(pixels); + } + } + + // Seed each slot with its decode error; inference overwrites decoded slots. + let mut results: Vec> = preprocessed + .into_iter() + .map(|r| match r { + Ok(_) => Err(anyhow::anyhow!( + "tagging inference did not run for this image" + )), + Err(error) => Err(error), + }) + .collect(); + + if batch_slots.is_empty() { + return results; // nothing decoded + } + + match infer(&batch_pixels, batch_slots.len()) { + Ok(outputs) => { + // `infer` must return exactly one output per packed image; the zip + // below would otherwise silently leave trailing slots as errors. + debug_assert_eq!(outputs.len(), batch_slots.len()); + for (&slot, output) in batch_slots.iter().zip(outputs) { + results[slot] = Ok(output); + } + } + Err(batch_error) => { + log::warn!( + "{model_label} batch inference failed for {} images, falling back to per-image: {batch_error}", + batch_slots.len() + ); + for (k, &slot) in batch_slots.iter().enumerate() { + let one = &batch_pixels[k * stride..(k + 1) * stride]; + results[slot] = infer(one, 1).and_then(|mut out| { + out.drain(..) + .next() + .ok_or_else(|| anyhow::anyhow!("tagger produced no output for image")) + }); + } + } + } + + results +} + // --------------------------------------------------------------------------- -// Tagger implementation +// WD tagger implementation // --------------------------------------------------------------------------- pub struct WdTagger { @@ -509,85 +691,6 @@ impl WdTagger { }) } - /// Tag a batch of images in a single forward pass. Returns one result per - /// input path, in the same order. Per-image decode failures — and, if the - /// batched inference itself fails (e.g. a model pinned to batch=1), a - /// per-image inference fallback — are reflected in the individual `Result`s. - pub fn run_batch( - &mut self, - image_paths: &[PathBuf], - max_tags: usize, - ) -> Vec> { - if image_paths.is_empty() { - return Vec::new(); - } - - let started_at = Instant::now(); - let input_size = self.input_size; - let stride = input_size * input_size * 3; - - // Decode + preprocess every image in parallel, keeping results aligned to - // the inputs so a failure stays attached to its own path. - let preprocessed: Vec>> = image_paths - .par_iter() - .map(|path| preprocess_image(path, input_size)) - .collect(); - - // Pack the successfully-decoded images into one contiguous buffer for a - // single batched inference, remembering which input slot each came from. - let mut batch_slots: Vec = Vec::new(); - let mut batch_pixels: Vec = Vec::with_capacity(image_paths.len() * stride); - for (i, result) in preprocessed.iter().enumerate() { - if let Ok(pixels) = result { - batch_slots.push(i); - batch_pixels.extend_from_slice(pixels); - } - } - - // Seed each slot with its decode error; inference overwrites the ones - // that decoded. The placeholder error never survives a decoded slot. - let mut results: Vec> = preprocessed - .into_iter() - .map(|r| match r { - Ok(_) => Err(anyhow::anyhow!( - "tagging inference did not run for this image" - )), - Err(error) => Err(error), - }) - .collect(); - - if batch_slots.is_empty() { - return results; // nothing decoded - } - - match self.infer_batch(&batch_pixels, batch_slots.len(), max_tags) { - Ok(outputs) => { - for (&slot, output) in batch_slots.iter().zip(outputs) { - results[slot] = Ok(output); - } - } - Err(batch_error) => { - log::warn!( - "WD tagger batch inference failed for {} images, falling back to per-image: {batch_error}", - batch_slots.len() - ); - for (k, &slot) in batch_slots.iter().enumerate() { - let pixels = batch_pixels[k * stride..(k + 1) * stride].to_vec(); - results[slot] = self.infer_one(pixels, max_tags); - } - } - } - - log::debug!( - "WD tagger batch: {} images ({} decoded) in {:?}", - image_paths.len(), - batch_slots.len(), - started_at.elapsed(), - ); - - results - } - /// Run `count` already-preprocessed images (contiguous `[count, H, W, 3]` /// pixels) through the model in one forward pass. fn infer_batch( @@ -632,14 +735,6 @@ impl WdTagger { .collect()) } - /// Run a single preprocessed image through the model. - fn infer_one(&mut self, image_array: Vec, max_tags: usize) -> Result { - self.infer_batch(&image_array, 1, max_tags)? - .into_iter() - .next() - .ok_or_else(|| anyhow::anyhow!("tagger produced no output for image")) - } - /// Convert one image's class probabilities into its rating + sorted tags. /// Associated (not `&self`) so callers can hold a disjoint session borrow. fn tags_from_probs( @@ -678,6 +773,177 @@ impl WdTagger { } } +impl Tagger for WdTagger { + fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec> { + if image_paths.is_empty() { + return Vec::new(); + } + let input_size = self.input_size; + let preprocessed: Vec>> = image_paths + .par_iter() + .map(|path| preprocess_image(path, input_size)) + .collect(); + assemble_batch( + preprocessed, + input_size * input_size * 3, + "WD tagger", + |pixels, count| self.infer_batch(pixels, count, max_tags), + ) + } + + fn model_name(&self) -> &'static str { + WD_TAGGER_MODEL_NAME + } +} + +// --------------------------------------------------------------------------- +// JoyTag implementation +// JoyTag uses the Danbooru tag schema but generalizes to photographic content +// and is strong on NSFW concepts. It has no rating output, so the explicitness +// rating is derived from its NSFW tags (see `joytag_rating`). Input is NCHW, +// RGB, CLIP-normalized — see `preprocess_joytag`. +// --------------------------------------------------------------------------- + +pub struct JoyTagger { + session: Session, + labels: Vec, + threshold: f32, + input_size: usize, +} + +impl JoyTagger { + pub fn new(app_data_dir: &Path) -> Result { + let started_at = Instant::now(); + + let status = tagger_model_status(app_data_dir); + if !status.ready { + anyhow::bail!( + "JoyTag model is missing {} required file(s): {}", + status.missing_files.len(), + status.missing_files.join(", ") + ); + } + + let local_dir = model_dir(app_data_dir); + + // Shared ONNX runtime DLLs (see WdTagger::new). + let caption_model_dir = app_data_dir.join("models").join("florence-2-base-ft"); + crate::captioner::ensure_onnx_runtime(&caption_model_dir).map_err(|e| { + anyhow::anyhow!( + "ONNX Runtime not initialised — download the Florence-2 caption model first \ + to get the shared runtime DLLs. Original error: {e}" + ) + })?; + + let acceleration = tagger_acceleration(app_data_dir); + let threshold = joytag_threshold(app_data_dir); + let model_path = local_dir.join("model.onnx"); + let labels_path = local_dir.join("top_tags.txt"); + + let session = create_tagger_session(&model_path, acceleration)?; + + // JoyTag uses NCHW (N, 3, H, W); the spatial size lives at axis 2. + let (input_size, batch_axis) = { + let inputs = session.inputs(); + inputs + .first() + .and_then(|inp| { + if let ort::value::ValueType::Tensor { shape, .. } = inp.dtype() { + let size = shape.get(2).copied().filter(|&d| d > 0).map(|d| d as usize); + Some((size.unwrap_or(448), shape.first().copied())) + } else { + None + } + }) + .unwrap_or((448, None)) + }; + + let labels = load_joytag_labels(&labels_path)?; + + log::info!( + "JoyTag loaded in {:?} ({} tags, input {}x{}, batch axis {:?}, {:?} acceleration)", + started_at.elapsed(), + labels.len(), + input_size, + input_size, + batch_axis, + acceleration, + ); + + Ok(Self { + session, + labels, + threshold, + input_size, + }) + } + + /// Run `count` already-preprocessed images (contiguous `[count, 3, H, W]` + /// pixels) through the model in one forward pass. + fn infer_batch( + &mut self, + pixels: &[f32], + count: usize, + max_tags: usize, + ) -> Result> { + let input = Tensor::from_array(( + [count, 3usize, self.input_size, self.input_size], + pixels.to_vec().into_boxed_slice(), + )) + .map_err(|error| anyhow::anyhow!("{error}"))?; + + let input_name: String = self.session.inputs()[0].name().to_string(); + let outputs = self + .session + .run(ort::inputs! { input_name.as_str() => input }) + .map_err(|error| anyhow::anyhow!("{error}"))?; + + let (_, logits) = outputs[0] + .try_extract_tensor::() + .map_err(|error| anyhow::anyhow!("{error}"))?; + + let n_labels = self.labels.len(); + if logits.len() != count * n_labels { + anyhow::bail!( + "Model output length {} does not match {} images x {} labels", + logits.len(), + count, + n_labels + ); + } + + let labels = &self.labels; + let threshold = self.threshold; + Ok(logits + .chunks_exact(n_labels) + .map(|row| joytag_tags_from_logits(labels, threshold, row, max_tags)) + .collect()) + } +} + +impl Tagger for JoyTagger { + fn run_batch(&mut self, image_paths: &[PathBuf], max_tags: usize) -> Vec> { + if image_paths.is_empty() { + return Vec::new(); + } + let input_size = self.input_size; + let preprocessed: Vec>> = image_paths + .par_iter() + .map(|path| preprocess_joytag(path, input_size)) + .collect(); + assemble_batch( + preprocessed, + input_size * input_size * 3, + "JoyTag", + |pixels, count| self.infer_batch(pixels, count, max_tags), + ) + } + + fn model_name(&self) -> &'static str { + JOYTAG_MODEL_NAME + } +} + // --------------------------------------------------------------------------- // Session creation – mirrors captioner's `create_session` // --------------------------------------------------------------------------- @@ -771,12 +1037,132 @@ fn load_labels(path: &Path) -> Result> { } // --------------------------------------------------------------------------- -// Image preprocessing -// WD tagger expects: (1, H, W, 3) float32, raw [0,255] values, BGR channel -// order, padded to square with white (255,255,255). +// JoyTag: label loading, threshold, rating derivation // --------------------------------------------------------------------------- -fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { +/// JoyTag labels: one tag per line, index == output position. Underscores are +/// replaced with spaces to match the display style used elsewhere. +fn load_joytag_labels(path: &Path) -> Result> { + let content = std::fs::read_to_string(path)?; + let labels: Vec = content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| line.replace('_', " ")) + .collect(); + if labels.is_empty() { + anyhow::bail!("top_tags.txt is empty or could not be parsed"); + } + Ok(labels) +} + +/// JoyTag detection threshold. Honours the shared `tagger_threshold` setting if +/// the user has set it, otherwise uses JoyTag's recommended default (0.4). +fn joytag_threshold(app_data_dir: &Path) -> f32 { + match std::fs::read_to_string(app_data_dir.join(TAGGER_THRESHOLD_FILE)) { + Ok(value) => value + .trim() + .parse::() + .unwrap_or(JOYTAG_DEFAULT_THRESHOLD) + .clamp(0.01, 1.0), + Err(_) => JOYTAG_DEFAULT_THRESHOLD, + } +} + +fn sigmoid(x: f32) -> f32 { + 1.0 / (1.0 + (-x).exp()) +} + +// Explicitness buckets for deriving a rating from JoyTag's tags (highest match +// wins). Names use spaces, since underscores are stripped on load. Tunable. +const JOYTAG_EXPLICIT_TAGS: &[&str] = &[ + "sex", + "vaginal", + "anal", + "oral", + "fellatio", + "cunnilingus", + "penis", + "pussy", + "cum", + "ejaculation", + "erection", + "handjob", + "paizuri", + "masturbation", +]; +const JOYTAG_QUESTIONABLE_TAGS: &[&str] = &[ + "nude", + "completely nude", + "nipples", + "topless", + "bottomless", + "pubic hair", + "areola", + "areolae", +]; +const JOYTAG_SENSITIVE_TAGS: &[&str] = &[ + "lingerie", + "underwear", + "panties", + "swimsuit", + "bikini", + "cleavage", + "bra", + "midriff", +]; + +/// Derive an explicitness rating from JoyTag's tags. JoyTag has no rating +/// output, so this maps its NSFW-content tags onto the WD-style buckets. +fn joytag_rating(tags: &[TagResult]) -> String { + let has = |bucket: &[&str]| tags.iter().any(|t| bucket.contains(&t.tag.as_str())); + if has(JOYTAG_EXPLICIT_TAGS) { + "explicit".to_string() + } else if has(JOYTAG_QUESTIONABLE_TAGS) { + "questionable".to_string() + } else if has(JOYTAG_SENSITIVE_TAGS) { + "sensitive".to_string() + } else { + "general".to_string() + } +} + +/// Convert one image's JoyTag logits into sorted tags + a derived rating. +fn joytag_tags_from_logits( + labels: &[String], + threshold: f32, + logits: &[f32], + max_tags: usize, +) -> TaggerOutput { + let mut tags: Vec = labels + .iter() + .zip(logits.iter()) + .filter_map(|(name, logit)| { + let confidence = sigmoid(*logit); + (confidence >= threshold).then(|| TagResult { + tag: name.clone(), + confidence, + }) + }) + .collect(); + tags.sort_by(|a, b| b.confidence.total_cmp(&a.confidence)); + + // Derive the rating from all kept tags before truncating to max_tags. + let rating = joytag_rating(&tags); + tags.truncate(max_tags); + + TaggerOutput { tags, rating } +} + +// --------------------------------------------------------------------------- +// Image preprocessing +// Both taggers pad to square with white and resize to the model input size; +// they differ only in channel order, value range, and tensor layout. +// --------------------------------------------------------------------------- + +/// Decode an image, composite any alpha onto white, pad to a centered square, +/// and resize to `target_size`. Shared by both taggers. +fn decode_pad_resize(image_path: &Path, target_size: usize) -> Result { let image = ImageReader::open(image_path)?.decode()?; // Composite any alpha channel onto a white background. @@ -787,22 +1173,25 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { image::imageops::overlay(&mut canvas_rgba, &image_rgba, 0, 0); let image_rgb = DynamicImage::ImageRgba8(canvas_rgba).to_rgb8(); - // Pad to square. + // Pad to a centered square. let max_dim = width.max(height); let pad_left = (max_dim - width) / 2; let pad_top = (max_dim - height) / 2; let mut square = image::RgbImage::from_pixel(max_dim, max_dim, image::Rgb([255, 255, 255])); image::imageops::overlay(&mut square, &image_rgb, pad_left as i64, pad_top as i64); - // Resize to model input size. - let resized = image::imageops::resize( + // Resize to model input size (CatmullRom ≈ PIL BICUBIC). + Ok(image::imageops::resize( &square, target_size as u32, target_size as u32, FilterType::CatmullRom, - ); + )) +} - // Flatten to (H, W, 3) float32 in BGR order, values in [0, 255]. +/// WD tagger input: (N, H, W, 3) float32, raw [0,255] values, BGR order. +fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { + let resized = decode_pad_resize(image_path, target_size)?; let mut pixel_values = vec![0.0f32; target_size * target_size * 3]; for (x, y, pixel) in resized.enumerate_pixels() { let base = (y as usize * target_size + x as usize) * 3; @@ -811,6 +1200,21 @@ fn preprocess_image(image_path: &Path, target_size: usize) -> Result> { pixel_values[base + 1] = f32::from(pixel[1]); // G pixel_values[base + 2] = f32::from(pixel[0]); // R } - + Ok(pixel_values) +} + +/// JoyTag input: (N, 3, H, W) float32, RGB, CLIP-normalized ((x/255 − mean)/std). +fn preprocess_joytag(image_path: &Path, target_size: usize) -> Result> { + let resized = decode_pad_resize(image_path, target_size)?; + let plane = target_size * target_size; + let mut pixel_values = vec![0.0f32; 3 * plane]; + for (x, y, pixel) in resized.enumerate_pixels() { + let idx = y as usize * target_size + x as usize; + // Channel-major (NCHW): R plane, then G, then B. + for c in 0..3 { + pixel_values[c * plane + idx] = + (f32::from(pixel[c]) / 255.0 - JOYTAG_MEAN[c]) / JOYTAG_STD[c]; + } + } Ok(pixel_values) } From 705f8c2e567af8092cef0f9b7d396d67e99e0a87 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 09:51:30 +0100 Subject: [PATCH 02/13] feat(tagger): Settings picker to switch tagging model (WD / JoyTag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Tagging model" control to Settings → AI Workspace that calls get/set_tagger_model, alongside the existing acceleration/threshold/batch controls. Switching refreshes the model status so the download/ready row and the model name/description reflect the selected model, and the threshold hint shows the model-appropriate default (0.4 for JoyTag, 0.35 for WD). Frontend only; mirrors the existing acceleration toggle pattern. End-to-end JoyTag tagging still needs the model files on disk to verify. --- src/components/SettingsModal.tsx | 83 ++++++++++++++++++++++++++++++-- src/store.ts | 28 +++++++++++ 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 3d09b97..c36dc30 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { AppTheme, CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggerModel, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; import { FfmpegStatusRow } from "./onboarding/StepWelcome"; import { ThemedDropdown } from "./ThemedDropdown"; import { getChangelogForVersion } from "../changelog"; @@ -100,6 +100,44 @@ function ScopeButton({ scope, current, onSelect, children }: { ); } +// Display metadata for each selectable tagging model. +const TAGGER_MODELS: Record = { + wd: { + name: "WD SwinV2 Tagger v3", + tab: "WD (anime)", + description: + "Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.", + }, + joytag: { + name: "JoyTag", + tab: "JoyTag (general)", + description: + "Booru-schema tagger that also handles photographic content and is strong on NSFW concepts. The explicitness rating is derived from its tags.", + }, +}; + +function TaggerModelButton({ model, current, onSelect, children }: { + model: TaggerModel; + current: TaggerModel; + onSelect: (model: TaggerModel) => void; + children: React.ReactNode; +}) { + const active = model === current; + return ( + + ); +} + function TaggerAccelerationButton({ acceleration, current, onSelect, children }: { acceleration: TaggerAcceleration; current: TaggerAcceleration; @@ -129,6 +167,8 @@ export function SettingsModal() { const [taggerClearing, setTaggerClearing] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationError, setTaggerAccelerationError] = useState(null); + const [taggerModelSwitching, setTaggerModelSwitching] = useState(false); + const [taggerModelSwitchError, setTaggerModelSwitchError] = useState(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); const [taggerThresholdError, setTaggerThresholdError] = useState(null); @@ -173,6 +213,9 @@ export function SettingsModal() { const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel); const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration); const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration); + const taggerModel = useGalleryStore((state) => state.taggerModel); + const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel); + const setTaggerModel = useGalleryStore((state) => state.setTaggerModel); const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold); const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold); const loadTaggerBatchSize = useGalleryStore((state) => state.loadTaggerBatchSize); @@ -210,6 +253,7 @@ export function SettingsModal() { useEffect(() => { if (!settingsOpen) return; void loadTaggerModelStatus(); + void loadTaggerModel(); void loadTaggerAcceleration(); void loadTaggerThreshold(); void loadTaggerBatchSize(); @@ -221,7 +265,7 @@ export function SettingsModal() { }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); + }, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); useEffect(() => { if (!settingsOpen || activeSection !== "general") return; @@ -365,10 +409,39 @@ export function SettingsModal() { {activeSection === "workspace" ? (
+ +
+
+ {(["wd", "joytag"] as const).map((model) => ( + { + if (nextModel === taggerModel) return; + setTaggerModelSwitching(true); + setTaggerModelSwitchError(null); + void setTaggerModel(nextModel) + .catch((error: unknown) => setTaggerModelSwitchError(String(error))) + .finally(() => setTaggerModelSwitching(false)); + }} + > + {TAGGER_MODELS[model].tab} + + ))} +
+ {taggerModelSwitchError ? ( +

{taggerModelSwitchError}

+ ) : ( +

{taggerModelSwitching ? "Switching..." : `Current: ${TAGGER_MODELS[taggerModel].name}`}

+ )} +
+
+ - WD SwinV2 Tagger v3{" "} + {TAGGER_MODELS[taggerModel].name}{" "} {taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed"} @@ -376,7 +449,7 @@ export function SettingsModal() { } - description="Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds." + description={TAGGER_MODELS[taggerModel].description} >
{taggerReady ? ( @@ -469,7 +542,7 @@ export function SettingsModal() { {taggerThresholdError ? (

{taggerThresholdError}

) : ( -

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+

{taggerThresholdSaving ? "Saving..." : `Default: ${taggerModel === "joytag" ? "0.4" : "0.35"}`}

)}
diff --git a/src/store.ts b/src/store.ts index 2270547..49f08d9 100644 --- a/src/store.ts +++ b/src/store.ts @@ -51,6 +51,7 @@ export type SearchCommand = "filename" | "semantic" | "tag"; export type CaptionAcceleration = "auto" | "cpu" | "directml"; export type CaptionDetail = "short" | "detailed" | "paragraph"; export type TaggerAcceleration = "auto" | "cpu" | "directml"; +export type TaggerModel = "wd" | "joytag"; export type AiRating = "general" | "sensitive" | "questionable" | "explicit"; export type TaggingQueueScope = "all" | "selected"; export type SimilarScope = "all_media" | "current_folder" | "current_album"; @@ -424,6 +425,7 @@ interface GalleryState { taggerModelPreparing: boolean; taggerModelError: string | null; taggerModelProgress: TaggerModelProgress | null; + taggerModel: TaggerModel; taggerAcceleration: TaggerAcceleration; taggerThreshold: number; taggerBatchSize: number; @@ -551,6 +553,8 @@ interface GalleryState { deleteTaggerModel: () => Promise; loadTaggerAcceleration: () => Promise; setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise; + loadTaggerModel: () => Promise; + setTaggerModel: (model: TaggerModel) => Promise; loadTaggerThreshold: () => Promise; setTaggerThreshold: (threshold: number) => Promise; loadTaggerBatchSize: () => Promise; @@ -902,6 +906,7 @@ export const useGalleryStore = create((set, get) => ({ taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null, + taggerModel: "wd", taggerAcceleration: "auto", taggerThreshold: 0.35, taggerBatchSize: 8, @@ -2158,6 +2163,29 @@ export const useGalleryStore = create((set, get) => ({ set({ taggerAcceleration, taggerRuntimeProbe: null }); }, + loadTaggerModel: async () => { + try { + const taggerModel = await invoke("get_tagger_model"); + set({ taggerModel }); + } catch (error) { + set({ taggerModelError: String(error) }); + } + }, + + setTaggerModel: async (model) => { + const taggerModel = await invoke("set_tagger_model", { + params: { model }, + }); + // Switching models changes which files are required on disk, so refresh the + // status too — the download/ready UI must reflect the newly-selected model. + try { + const taggerModelStatus = await invoke("get_tagger_model_status"); + set({ taggerModel, taggerModelStatus, taggerModelError: null, taggerRuntimeProbe: null }); + } catch (error) { + set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) }); + } + }, + loadTaggerThreshold: async () => { try { const taggerThreshold = await invoke("get_tagger_threshold"); From 168513411612ae3adf9f83e6e81251a4095d478b Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 10:08:55 +0100 Subject: [PATCH 03/13] fix(tagger): model-neutral session log; changelog for JoyTag create_tagger_session is shared by both models but logged "WD tagger: using CPU execution provider" even when loading JoyTag. Make the CPU/DirectML session logs model-neutral. Add the JoyTag tagging-model picker to the changelog now that it's runtime-verified. --- CHANGELOG.md | 6 ++++++ src-tauri/src/tagger.rs | 6 ++---- src/dev/mockBackend.ts | 3 +++ src/store.ts | 3 ++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e36ec2f..32c1851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) changes grouped into collapsible Added / Changed / Fixed sections. - **Build badge in Settings** — the version line in Settings → Updates now shows whether the running build is the CPU or CUDA (GPU-accelerated) variant. +- **Choose your tagging model** — Settings → AI Workspace now lets you switch the + AI tagger between the WD tagger (anime-focused) and **JoyTag**, which also + handles photographic content and is stronger on NSFW concepts — a better fit + for real-photo libraries. Each model downloads on demand, and tags are + attributed to the model that produced them. JoyTag has no built-in rating, so + its explicitness rating is derived from its tags. ### Changed diff --git a/src-tauri/src/tagger.rs b/src-tauri/src/tagger.rs index 45e5150..4923139 100644 --- a/src-tauri/src/tagger.rs +++ b/src-tauri/src/tagger.rs @@ -987,15 +987,13 @@ fn create_tagger_session(path: &Path, acceleration: TaggerAcceleration) -> Resul let mut builder = match acceleration { TaggerAcceleration::Cpu => { - log::info!( - "WD tagger: using CPU execution provider ({intra_threads} intra-op threads)" - ); + log::info!("Tagger: using CPU execution provider ({intra_threads} intra-op threads)"); builder } TaggerAcceleration::Auto => builder .with_execution_providers([ep::DirectML::default().build().fail_silently()]) .unwrap_or_else(|error| { - log::info!("WD tagger: DirectML unavailable, falling back to CPU"); + log::info!("Tagger: DirectML unavailable, falling back to CPU"); error.recover() }), TaggerAcceleration::Directml => builder diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 2749b7f..872d6e6 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -344,6 +344,9 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return 12; case "get_tagger_model_status": return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] }; + case "get_tagger_model": + case "set_tagger_model": + return p.model ?? "wd"; case "get_tagger_acceleration": case "set_tagger_acceleration": return p.acceleration ?? "auto"; diff --git a/src/store.ts b/src/store.ts index 49f08d9..3e92035 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2166,7 +2166,8 @@ export const useGalleryStore = create((set, get) => ({ loadTaggerModel: async () => { try { const taggerModel = await invoke("get_tagger_model"); - set({ taggerModel }); + // Never clobber the valid default with a missing/blank backend response. + if (taggerModel) set({ taggerModel }); } catch (error) { set({ taggerModelError: String(error) }); } From e4a63c8bb05e3bac565f211e7177979fe5bf0143 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 11:24:21 +0100 Subject: [PATCH 04/13] fix: filter noisy AI tags Add an editable AI tag removal list and apply it to WD/JoyTag output before tags are stored. Clean existing generated AI tags during database migration while leaving manual user tags untouched. --- src-tauri/src/ai_tag_filter.rs | 45 ++++++++++++++++++++++++++++++++++ src-tauri/src/db.rs | 38 +++++++++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src-tauri/src/tagger.rs | 10 +++++--- 4 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/ai_tag_filter.rs diff --git a/src-tauri/src/ai_tag_filter.rs b/src-tauri/src/ai_tag_filter.rs new file mode 100644 index 0000000..d683235 --- /dev/null +++ b/src-tauri/src/ai_tag_filter.rs @@ -0,0 +1,45 @@ +/// AI-generated tags that are too broad/noisy to be useful in this gallery. +/// Edit this list to change what the tagger removes. Manual user tags are not +/// affected. +pub const AI_TAG_REMOVAL_LIST: &[&str] = &["1girl", "1boy", "no humans", "2girls", "2boys"]; + +fn normalize_tag_for_removal(tag: &str) -> String { + tag.trim() + .chars() + .filter(|c| !matches!(c, ' ' | '_' | '-')) + .flat_map(char::to_lowercase) + .collect() +} + +pub fn is_removed_ai_tag(tag: &str) -> bool { + let normalized = normalize_tag_for_removal(tag); + AI_TAG_REMOVAL_LIST + .iter() + .any(|blocked| normalize_tag_for_removal(blocked) == normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn removed_ai_tags_match_common_spellings() { + for tag in [ + "1girl", + "1 girl", + "1_girl", + "1-girl", + "NO_HUMANS", + "no humans", + ] { + assert!(is_removed_ai_tag(tag), "{tag} should be removed"); + } + } + + #[test] + fn removed_ai_tags_do_not_match_unrelated_tags() { + for tag in ["girl", "boy", "humans", "solo", "landscape"] { + assert!(!is_removed_ai_tag(tag), "{tag} should be kept"); + } + } +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f6b2bd6..361fa85 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,4 +1,4 @@ -use crate::vector; +use crate::{ai_tag_filter, vector}; use anyhow::Result; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; @@ -378,6 +378,42 @@ pub fn migrate(conn: &Connection) -> Result<()> { )?; vector::migrate(conn)?; + remove_filtered_ai_tags(conn)?; + Ok(()) +} + +fn remove_filtered_ai_tags(conn: &Connection) -> Result<()> { + let mut stmt = conn.prepare("SELECT id, tag FROM image_tags WHERE source = 'ai'")?; + let filtered_ids = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .filter_map(|row| match row { + Ok((id, tag)) if ai_tag_filter::is_removed_ai_tag(&tag) => Some(Ok(id)), + Ok(_) => None, + Err(error) => Some(Err(error)), + }) + .collect::>>()?; + drop(stmt); + + if filtered_ids.is_empty() { + return Ok(()); + } + + let tx = conn.unchecked_transaction()?; + { + let mut delete_stmt = tx.prepare("DELETE FROM image_tags WHERE id = ?1")?; + for id in &filtered_ids { + delete_stmt.execute([id])?; + } + } + tx.execute("DELETE FROM tag_cloud_cache", [])?; + tx.commit()?; + + log::info!( + "Removed {} filtered AI tag(s) from existing library data", + filtered_ids.len() + ); Ok(()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 18a7eac..880c95d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +mod ai_tag_filter; mod captioner; mod color; mod commands; diff --git a/src-tauri/src/tagger.rs b/src-tauri/src/tagger.rs index 4923139..2b9616e 100644 --- a/src-tauri/src/tagger.rs +++ b/src-tauri/src/tagger.rs @@ -1,3 +1,4 @@ +use crate::ai_tag_filter; use anyhow::Result; use hf_hub::{api::sync::Api, Repo, RepoType}; use image::{imageops::FilterType, DynamicImage, ImageReader}; @@ -759,6 +760,7 @@ impl WdTagger { .filter(|(entry, prob)| { (entry.category == GENERAL_CATEGORY || entry.category == CHARACTER_CATEGORY) && **prob >= threshold + && !ai_tag_filter::is_removed_ai_tag(&entry.name) }) .map(|(entry, prob)| TagResult { tag: entry.name.clone(), @@ -1137,9 +1139,11 @@ fn joytag_tags_from_logits( .zip(logits.iter()) .filter_map(|(name, logit)| { let confidence = sigmoid(*logit); - (confidence >= threshold).then(|| TagResult { - tag: name.clone(), - confidence, + (confidence >= threshold && !ai_tag_filter::is_removed_ai_tag(name)).then(|| { + TagResult { + tag: name.clone(), + confidence, + } }) }) .collect(); From 949382f28c7447769cfd90fec645f981237bc069 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 13:21:43 +0100 Subject: [PATCH 05/13] feat: related-tags atlas in Explore view Hovering a tag in Explore now loads and displays co-occurring tags as a weighted cloud. New `get_related_tags` SQL self-join (db.rs/commands.rs), `loadRelatedTags` store action with per-folder keyed cache, and TagCloud atlas UI with ResizeObserver-driven layout and RAF animation. Explore tag limit raised to 180; tag cloud auto-refreshes 700ms after new AI tagging completes. --- src-tauri/src/commands.rs | 36 ++- src-tauri/src/db.rs | 28 +++ src-tauri/src/lib.rs | 1 + src/components/TagCloud.tsx | 480 +++++++++++++++++++++++++++++------- src/dev/mockBackend.ts | 16 +- src/dev/mockFixtures.ts | 90 ++++++- src/store.ts | 61 ++++- 7 files changed, 608 insertions(+), 104 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 48595ca..445f10c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -180,6 +180,19 @@ pub struct GetExploreTagsParams { pub limit: Option, } +#[derive(Deserialize)] +pub struct GetRelatedTagsParams { + pub tag: String, + pub folder_id: Option, + pub limit: Option, +} + +#[derive(Serialize)] +pub struct RelatedTagEntry { + pub tag: String, + pub shared_count: i64, +} + #[derive(Deserialize)] pub struct SearchTagsAutocompleteParams { pub query: String, @@ -1277,7 +1290,28 @@ pub async fn get_explore_tags( params: GetExploreTagsParams, ) -> Result, String> { let conn = db.get().map_err(|e| e.to_string())?; - db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)) + db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(180)) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_related_tags( + db: State<'_, DbState>, + params: GetRelatedTagsParams, +) -> Result, String> { + let tag = params.tag.trim(); + if tag.is_empty() { + return Ok(vec![]); + } + + let conn = db.get().map_err(|e| e.to_string())?; + db::get_related_tags(&conn, tag, params.folder_id, params.limit.unwrap_or(16)) + .map(|entries| { + entries + .into_iter() + .map(|(tag, shared_count)| RelatedTagEntry { tag, shared_count }) + .collect() + }) .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 361fa85..6e51895 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -2436,6 +2436,34 @@ pub fn get_explore_tags( Ok(rows) } +pub fn get_related_tags( + conn: &Connection, + tag: &str, + folder_id: Option, + limit: usize, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT other.tag, COUNT(DISTINCT other.image_id) AS shared_count + FROM image_tags base + JOIN image_tags other ON other.image_id = base.image_id + JOIN images i ON i.id = base.image_id + WHERE base.tag = ?1 + AND other.tag != base.tag + AND (?2 IS NULL OR i.folder_id = ?2) + GROUP BY other.tag + ORDER BY shared_count DESC, other.tag ASC + LIMIT ?3", + )?; + + let rows = stmt + .query_map(params![tag, folder_id, limit as i64], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })? + .collect::>>()?; + + Ok(rows) +} + type FailedEmbeddingRow = (i64, String, String, Option); pub fn get_failed_embedding_images( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 880c95d..c5d167b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -197,6 +197,7 @@ pub fn run() { commands::get_worker_states, commands::get_tag_cloud, commands::get_explore_tags, + commands::get_related_tags, commands::get_images_by_ids, commands::get_failed_embedding_images, commands::get_failed_tagging_images, diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index fc586d3..9b9002a 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -1,6 +1,6 @@ -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; -import { ExploreTagEntry, TagCloudEntry, useGalleryStore } from "../store"; +import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -224,59 +224,361 @@ function CloudCard({ node, onOpen, animated }: { node: PlacedNode; onOpen: (imag ); } -// Actual tag cloud — word size driven by log-scaled frequency -function TagWord({ - entry, - index, - logMin, - logRange, - onSearch, -}: { +interface AtlasNode { entry: ExploreTagEntry; index: number; - logMin: number; - logRange: number; + x: number; + y: number; + w: number; + h: number; + fontSize: number; + ratio: number; + accent: string; + driftX: number; + driftY: number; +} + +interface TagAnchor { + x: number; + y: number; +} + +const TAG_ATLAS_MAX_VISIBLE = 132; +const TAG_ATLAS_DENSE_THRESHOLD = 120; + +function buildTagAtlas(entries: ExploreTagEntry[], containerW: number, containerH: number, isLight: boolean): AtlasNode[] { + if (!entries.length || containerW <= 0 || containerH <= 0) return []; + + const logs = entries.map((entry) => Math.log(Math.max(entry.count, 1))); + const logMin = Math.min(...logs); + const logRange = Math.max(...logs) - logMin || 1; + const cx = containerW / 2; + const cy = containerH * 0.48; + const spreadX = containerW * 0.47; + const spreadY = containerH * 0.46; + const accents = isLight ? LIGHT_ACCENTS : ACCENTS; + + const nodes = entries.map((entry, index) => { + const ratio = (Math.log(Math.max(entry.count, 1)) - logMin) / logRange; + const densityScale = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 0.94 : 1; + const fontSize = (9.75 + Math.pow(ratio, 0.92) * 19.5) * densityScale; + const maxWidthRatio = index < 48 ? 0.32 : index < 100 ? 0.26 : 0.2; + const textWidth = entry.tag.length * fontSize * 0.5 + (ratio > 0.55 ? 36 : 26); + const w = Math.min(containerW * maxWidthRatio, textWidth); + const h = fontSize * 1.18 + 14; + const radialRatio = Math.sqrt((index + 0.5) / entries.length); + const angle = index * GOLDEN_ANGLE; + return { + entry, + index, + x: cx + Math.cos(angle) * radialRatio * spreadX, + y: cy + Math.sin(angle) * radialRatio * spreadY, + w, + h, + fontSize, + ratio, + accent: accents[index % accents.length], + driftX: (seeded(index + 101) - 0.5) * 7, + driftY: (seeded(index + 113) - 0.5) * 6, + }; + }); + + const padX = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 4 : 10; + const padY = entries.length > TAG_ATLAS_DENSE_THRESHOLD ? 3 : 7; + const margin = 28; + for (let iter = 0; iter < 140; iter++) { + for (let a = 0; a < nodes.length; a++) { + const na = nodes[a]; + for (let b = a + 1; b < nodes.length; b++) { + const nb = nodes[b]; + const dx = nb.x - na.x; + const dy = nb.y - na.y; + const overlapX = (na.w + nb.w) / 2 + padX - Math.abs(dx); + const overlapY = (na.h + nb.h) / 2 + padY - Math.abs(dy); + if (overlapX <= 0 || overlapY <= 0) continue; + if (overlapX < overlapY) { + const push = (overlapX / 2) * (dx >= 0 ? 1 : -1); + nb.x += push; + na.x -= push; + } else { + const push = (overlapY / 2) * (dy >= 0 ? 1 : -1); + nb.y += push; + na.y -= push; + } + } + } + for (const node of nodes) { + node.x = Math.min(Math.max(node.x, node.w / 2 + margin), containerW - node.w / 2 - margin); + node.y = Math.min(Math.max(node.y, node.h / 2 + margin), containerH - node.h / 2 - margin); + if (node.x <= node.w / 2 + margin || node.x >= containerW - node.w / 2 - margin) { + node.y += (seeded(node.index + iter + 211) - 0.5) * 2; + } + if (node.y <= node.h / 2 + margin || node.y >= containerH - node.h / 2 - margin) { + node.x += (seeded(node.index + iter + 223) - 0.5) * 2; + } + } + } + + for (let iter = 0; iter < 5; iter++) { + const weighted = nodes.reduce( + (acc, node) => { + const weight = 1 + Math.pow(node.ratio, 1.35) * 9; + return { + x: acc.x + node.x * weight, + y: acc.y + node.y * weight, + weight: acc.weight + weight, + }; + }, + { x: 0, y: 0, weight: 0 }, + ); + const offsetX = containerW * 0.48 - weighted.x / weighted.weight; + const offsetY = containerH * 0.44 - weighted.y / weighted.weight; + if (Math.abs(offsetX) < 0.5 && Math.abs(offsetY) < 0.5) break; + for (const node of nodes) { + node.x = Math.min(Math.max(node.x + offsetX, node.w / 2 + margin), containerW - node.w / 2 - margin); + node.y = Math.min(Math.max(node.y + offsetY, node.h / 2 + margin), containerH - node.h / 2 - margin); + } + } + + return nodes; +} + +function TagAtlas({ + entries, + onSearch, + loadRelatedTags, +}: { + entries: ExploreTagEntry[]; onSearch: (tag: string) => void; + loadRelatedTags: (tag: string) => Promise; }) { const theme = useGalleryStore((state) => state.theme); const isLight = theme === "subtle-light"; - const ratio = logRange > 0 ? (Math.log(Math.max(entry.count, 1)) - logMin) / logRange : 0.5; - const fontSize = 11 + ratio * 28; // 11px – 39px - const accent = (isLight ? LIGHT_ACCENTS : ACCENTS)[index % ACCENTS.length]; - const tilt = (seeded(index + 5) - 0.5) * 7; - // Faint low-frequency words read fine as subtle white-on-dark, but the same low - // opacity is unreadable on the light theme's cream, so raise the floor there. - const minOpacity = isLight ? 0.6 : 0.4; + const reducedMotion = useReducedMotion(); + const canvasRef = useRef(null); + const buttonRefs = useRef(new Map()); + const [canvasSize, setCanvasSize] = useState({ w: 0, h: 0 }); + const [activeTag, setActiveTag] = useState(null); + const [relatedTags, setRelatedTags] = useState([]); + const [anchors, setAnchors] = useState>({}); + const visibleEntries = useMemo( + () => (entries.length > TAG_ATLAS_MAX_VISIBLE ? entries.slice(0, TAG_ATLAS_MAX_VISIBLE) : entries), + [entries], + ); + + useLayoutEffect(() => { + const el = canvasRef.current; + if (!el) return; + const update = () => { + const r = el.getBoundingClientRect(); + setCanvasSize({ w: r.width, h: r.height }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!activeTag) { + setRelatedTags([]); + return; + } + let cancelled = false; + void loadRelatedTags(activeTag).then((entries) => { + if (!cancelled) setRelatedTags(entries); + }); + return () => { + cancelled = true; + }; + }, [activeTag, loadRelatedTags]); + + const nodes = useMemo( + () => buildTagAtlas(visibleEntries, canvasSize.w, canvasSize.h, isLight), + [visibleEntries, canvasSize.w, canvasSize.h, isLight], + ); + const nodeByTag = useMemo(() => new Map(nodes.map((node) => [node.entry.tag, node])), [nodes]); + const activeNode = activeTag ? nodeByTag.get(activeTag) : undefined; + const minOpacity = isLight ? 0.62 : 0.42; + const visibleConnections = useMemo( + () => + activeNode + ? relatedTags + .map((related) => { + const node = nodeByTag.get(related.tag); + return node ? { node, related } : null; + }) + .filter((item): item is { node: AtlasNode; related: RelatedTagEntry } => item !== null) + .slice(0, visibleEntries.length > TAG_ATLAS_DENSE_THRESHOLD ? 6 : 10) + : [], + [activeNode, nodeByTag, relatedTags, visibleEntries.length], + ); + const activeAnchor = activeTag ? anchors[activeTag] : undefined; + const maxShared = Math.max(1, ...visibleConnections.map(({ related }) => related.shared_count)); + const connectedByTag = useMemo( + () => new Map(visibleConnections.map(({ node, related }) => [node.entry.tag, related])), + [visibleConnections], + ); + + const setButtonRef = useCallback((tag: string, element: HTMLButtonElement | null) => { + if (element) { + buttonRefs.current.set(tag, element); + } else { + buttonRefs.current.delete(tag); + } + }, []); + + const measureAnchors = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas || !activeTag) { + setAnchors({}); + return; + } + + const canvasRect = canvas.getBoundingClientRect(); + const tagsToMeasure = [activeTag, ...visibleConnections.map(({ node }) => node.entry.tag)]; + const nextAnchors: Record = {}; + for (const tag of tagsToMeasure) { + const element = buttonRefs.current.get(tag); + if (!element) continue; + const rect = element.getBoundingClientRect(); + nextAnchors[tag] = { + x: rect.left - canvasRect.left + rect.width / 2, + y: rect.top - canvasRect.top + rect.height / 2, + }; + } + setAnchors(nextAnchors); + }, [activeTag, visibleConnections]); + + useLayoutEffect(() => { + if (!activeTag) { + setAnchors({}); + return; + } + + let firstFrame = 0; + let secondFrame = 0; + const settleTimer = window.setTimeout(measureAnchors, 180); + firstFrame = window.requestAnimationFrame(() => { + measureAnchors(); + secondFrame = window.requestAnimationFrame(measureAnchors); + }); + return () => { + window.cancelAnimationFrame(firstFrame); + window.cancelAnimationFrame(secondFrame); + window.clearTimeout(settleTimer); + }; + }, [activeTag, canvasSize.w, canvasSize.h, measureAnchors]); return ( - - onSearch(entry.tag)} - > - 0.55 ? accent : isLight ? "#4b5563" : "rgba(255,255,255,0.82)" }} - > - {entry.tag} - - - {entry.count.toLocaleString()} - - - +
+ + {nodes.map((node) => { + const isActive = activeTag === node.entry.tag; + const connectedRelated = connectedByTag.get(node.entry.tag); + const isRelated = connectedRelated !== undefined; + const dimmed = activeTag !== null && !isActive && !isRelated; + const opacity = dimmed ? 0.2 : minOpacity + node.ratio * (1 - minOpacity); + return ( + + + + ); + })} +
); } @@ -290,6 +592,33 @@ function Spinner() { ); } +function ExploreLoadingPanel({ mode }: { mode: ExploreMode }) { + const title = mode === "visual" ? "Building visual clusters" : "Reading tag frequencies"; + const subtitle = + mode === "visual" + ? "Grouping similar images into browsable clusters." + : "Collecting tags from the current library scope."; + + return ( +
+
+
+ + {title} +
+

{subtitle}

+
+ +
+
+
+ ); +} + // Separate component so its useLayoutEffect fires when the canvas is actually // mounted — not at TagCloud mount time when the container may still be hidden // behind a loading state. @@ -502,6 +831,7 @@ export function TagCloud() { const exploreTagEntries = useGalleryStore((state) => state.exploreTagEntries); const exploreTagLoading = useGalleryStore((state) => state.exploreTagLoading); const loadExploreTags = useGalleryStore((state) => state.loadExploreTags); + const loadRelatedTags = useGalleryStore((state) => state.loadRelatedTags); const showVisualCluster = useGalleryStore((state) => state.showVisualCluster); const searchForTag = useGalleryStore((state) => state.searchForTag); const renameTag = useGalleryStore((state) => state.renameTag); @@ -515,17 +845,10 @@ export function TagCloud() { else void loadExploreTags(); }, [exploreMode, selectedFolderId, loadTagCloud, loadExploreTags]); - const { logMin, logRange } = useMemo(() => { - if (!exploreTagEntries.length) return { logMin: 0, logRange: 1 }; - const logs = exploreTagEntries.map((e) => Math.log(Math.max(e.count, 1))); - const lo = Math.min(...logs); - const hi = Math.max(...logs); - return { logMin: lo, logRange: hi - lo || 1 }; - }, [exploreTagEntries]); - const loading = exploreMode === "visual" ? tagCloudLoading : exploreTagLoading; const hasEntries = exploreMode === "visual" ? tagCloudEntries.length > 0 : exploreTagEntries.length > 0; const entryCount = exploreMode === "visual" ? tagCloudEntries.length : exploreTagEntries.length; + const visibleTagCount = Math.min(exploreTagEntries.length, TAG_ATLAS_MAX_VISIBLE); return (
@@ -541,7 +864,9 @@ export function TagCloud() { : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` - : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` + : visibleTagCount < entryCount + ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` + : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` : exploreMode === "visual" ? "No clusters — images need embeddings first" : "No tags — run the AI tagger or add tags manually"} @@ -583,11 +908,8 @@ export function TagCloud() {
- {loading ? ( -
- - {exploreMode === "visual" ? "Computing clusters…" : "Loading tags…"} -
+ {loading && !hasEntries ? ( + ) : !hasEntries ? (

@@ -597,30 +919,20 @@ export function TagCloud() {

) : exploreMode === "visual" ? ( - - ) : manageTags ? ( - - ) : ( - /* Tag cloud — words sized by log-scaled frequency, wrapped freely */ -
-
- {exploreTagEntries.map((entry, index) => ( - - ))} -
+
+
+ ) : manageTags ? ( +
+ +
+ ) : ( + )}
); diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 872d6e6..a4d8ddd 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -241,7 +241,21 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "get_tag_cloud": return db.scenario === "empty" ? [] : db.tagCloud; case "get_explore_tags": - return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 48)); + return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180)); + case "get_related_tags": { + const relatedCounts = new Map(); + for (const imageTags of Object.values(db.tagsByImageId)) { + if (!imageTags.some((tag) => tag.tag === p.tag)) continue; + for (const tag of imageTags) { + if (tag.tag === p.tag) continue; + relatedCounts.set(tag.tag, (relatedCounts.get(tag.tag) ?? 0) + 1); + } + } + return Array.from(relatedCounts.entries()) + .map(([tag, shared_count]) => ({ tag, shared_count })) + .sort((left, right) => right.shared_count - left.shared_count || left.tag.localeCompare(right.tag)) + .slice(0, Number(p.limit ?? 18)); + } case "suggest_image_tags": return ["select", "warm light"]; case "rename_tag": diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index fcb94c1..8cd5e8e 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -57,6 +57,68 @@ const tags = [ "blue hour", "select", ]; + +const hugeTagThemes = [ + "alpine", + "archive", + "botanical", + "cinematic", + "coastal", + "editorial", + "industrial", + "interior", + "macro", + "night", + "portrait", + "street", + "travel", + "urban", + "wildlife", + "workspace", +]; + +const hugeTagSubjects = [ + "glass", + "steel", + "water", + "stone", + "mist", + "foliage", + "market", + "signage", + "neon", + "shadow", + "reflection", + "texture", + "silhouette", + "motion", + "symmetry", + "detail", + "warm light", + "blue hour", + "gold accent", + "soft focus", + "hard light", + "negative space", + "foreground", + "background", + "wide angle", + "close crop", + "environment", + "still life", + "natural light", + "available light", +]; + +const hugeTags = [ + ...tags, + ...Array.from({ length: hugeTagThemes.length * hugeTagSubjects.length }, (_, index) => { + const theme = hugeTagThemes[index % hugeTagThemes.length]; + const subject = hugeTagSubjects[Math.floor(index / hugeTagThemes.length) % hugeTagSubjects.length]; + return `${theme} ${subject}`; + }), + ...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`), +]; const aiRatings: AiRating[] = ["general", "sensitive", "questionable"]; function daysAgo(days: number): string { @@ -136,17 +198,23 @@ function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { return images; } -function makeTags(images: ImageRecord[]): Record { +function tagVocabularyForScenario(scenario: MockScenario): string[] { + return scenario === "huge" ? hugeTags : tags; +} + +function makeTags(images: ImageRecord[], scenario: MockScenario): Record { + const vocabulary = tagVocabularyForScenario(scenario); + const tagCount = scenario === "huge" ? 9 : 3; return Object.fromEntries( images.map((image, index) => [ image.id, - [0, 1, 2].map((offset) => ({ + Array.from({ length: tagCount }, (_, offset) => ({ id: image.id * 10 + offset, image_id: image.id, - tag: tags[(index + offset) % tags.length], + tag: vocabulary[(index * (offset + 3) + offset * 29) % vocabulary.length], source: offset === 0 ? "user" : "ai", ai_model: offset === 0 ? null : "wd-vit-tagger-v3", - confidence: offset === 0 ? null : 0.72 + offset * 0.07, + confidence: offset === 0 ? null : Math.min(0.99, 0.72 + offset * 0.03), created_at: daysAgo(index + offset), })), ]), @@ -197,16 +265,18 @@ function makeTagCloud(images: ImageRecord[]): TagCloudEntry[] { }); } -function makeExploreTags(images: ImageRecord[]): ExploreTagEntry[] { - return tags.map((tag, index) => { +function makeExploreTags(images: ImageRecord[], scenario: MockScenario): ExploreTagEntry[] { + const vocabulary = tagVocabularyForScenario(scenario); + return vocabulary.map((tag, index) => { const representative = images[index % Math.max(images.length, 1)]; + const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3; return { tag, - count: Math.max(3, Math.round(images.length / (index + 3))), + count: Math.max(1, Math.round(images.length / divisor)), representative_image_id: representative?.id ?? index + 1, thumbnail_path: representative?.thumbnail_path ?? null, }; - }); + }).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); } function makeDuplicateGroups(images: ImageRecord[], scenario: MockScenario): DuplicateGroup[] { @@ -234,9 +304,9 @@ export function createMockDb(scenario: MockScenario): MockDb { images, albums, albumImageIds, - tagsByImageId: makeTags(images), + tagsByImageId: makeTags(images, scenario), tagCloud: makeTagCloud(images), - exploreTags: makeExploreTags(images), + exploreTags: makeExploreTags(images, scenario), backgroundJobs: makeProgress(folders, scenario), duplicateGroups: makeDuplicateGroups(images, scenario), duplicateScannedAt: scenario === "duplicates" ? Math.floor(Date.now() / 1000) - 420 : null, diff --git a/src/store.ts b/src/store.ts index 3e92035..e1c9bb3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -217,6 +217,11 @@ export interface ExploreTagEntry { thumbnail_path: string | null; } +export interface RelatedTagEntry { + tag: string; + shared_count: number; +} + export interface DuplicateGroup { file_hash: string; file_size: number; @@ -376,6 +381,7 @@ interface GalleryState { exploreTagEntries: ExploreTagEntry[]; exploreTagLoading: boolean; exploreTagsFolderId: number | null | undefined; + relatedTagsByKey: Record; indexingProgress: Record; mediaJobProgress: Record; cacheDir: string; @@ -480,8 +486,9 @@ interface GalleryState { closeImage: () => void; setView: (view: ActiveView) => void; setExploreMode: (mode: ExploreMode) => void; - loadTagCloud: () => Promise; - loadExploreTags: () => Promise; + loadTagCloud: (options?: { force?: boolean }) => Promise; + loadExploreTags: (options?: { force?: boolean }) => Promise; + loadRelatedTags: (tag: string) => Promise; showVisualCluster: (imageIds: number[]) => Promise; searchForTag: (tag: string) => void; loadSimilarImages: (imageId: number, folderId?: number | null, reset?: boolean, sourceFolderId?: number | null, albumId?: number | null) => Promise; @@ -615,6 +622,7 @@ const SIMILAR_DISTANCE_THRESHOLD = 0.24; let galleryRequestToken = 0; let tagCloudRequestToken = 0; let exploreTagRequestToken = 0; +let exploreTagRefreshTimer: ReturnType | null = null; function initialAiCaptionsEnabled(): boolean { if (typeof window === "undefined") return false; @@ -862,6 +870,7 @@ export const useGalleryStore = create((set, get) => ({ exploreTagEntries: [], exploreTagLoading: false, exploreTagsFolderId: undefined, + relatedTagsByKey: {}, indexingProgress: {}, mediaJobProgress: {}, cacheDir: "", @@ -1388,10 +1397,11 @@ export const useGalleryStore = create((set, get) => ({ setExploreMode: (exploreMode) => set({ exploreMode }), - loadTagCloud: async () => { + loadTagCloud: async (options) => { const { selectedFolderId, tagCloudFolderId, tagCloudLoading } = get(); + const force = options?.force ?? false; // Skip if already loaded for this folder and not currently loading - if (!tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { + if (!force && !tagCloudLoading && tagCloudFolderId !== undefined && tagCloudFolderId === selectedFolderId) { return; } const requestToken = ++tagCloudRequestToken; @@ -1409,19 +1419,20 @@ export const useGalleryStore = create((set, get) => ({ } }, - loadExploreTags: async () => { + loadExploreTags: async (options) => { const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); - if (!exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { + const force = options?.force ?? false; + if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { return; } const requestToken = ++exploreTagRequestToken; set({ exploreTagLoading: true, exploreTagsFolderId: selectedFolderId }); try { const entries = await invoke("get_explore_tags", { - params: { folder_id: selectedFolderId, limit: 48 }, + params: { folder_id: selectedFolderId, limit: 180 }, }); if (requestToken !== exploreTagRequestToken) return; - set({ exploreTagEntries: entries, exploreTagLoading: false }); + set({ exploreTagEntries: entries, exploreTagLoading: false, relatedTagsByKey: {} }); } catch (error) { if (requestToken !== exploreTagRequestToken) return; console.error("Failed to load explore tags:", error); @@ -1888,6 +1899,28 @@ export const useGalleryStore = create((set, get) => ({ void invoke("set_notifications_paused", { paused }).catch(() => {}); }, + loadRelatedTags: async (tag) => { + const trimmed = tag.trim(); + if (!trimmed) return []; + + const { selectedFolderId, relatedTagsByKey } = get(); + const key = `${selectedFolderId ?? "all"}:${trimmed}`; + if (relatedTagsByKey[key]) { + return relatedTagsByKey[key]; + } + + const entries = await invoke("get_related_tags", { + params: { tag: trimmed, folder_id: selectedFolderId, limit: 18 }, + }); + set((state) => ({ + relatedTagsByKey: { + ...state.relatedTagsByKey, + [key]: entries, + }, + })); + return entries; + }, + setTheme: (theme) => { window.localStorage.setItem(THEME_KEY, theme); document.documentElement.dataset.theme = theme; @@ -2887,6 +2920,18 @@ export const useGalleryStore = create((set, get) => ({ const unlistenThumbnails = await listen("media-updated", (event) => { const batch = event.payload; + const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); + if (taggingUpdated) { + set({ exploreTagsFolderId: undefined }); + if (get().activeView === "explore") { + if (!exploreTagRefreshTimer) { + exploreTagRefreshTimer = setTimeout(() => { + exploreTagRefreshTimer = null; + void get().loadExploreTags({ force: true }); + }, 700); + } + } + } set((state) => { const selectedImageUpdate = From d81624573d95d53e296c67881aaae87f5162b0d6 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 13:21:53 +0100 Subject: [PATCH 06/13] feat: persist worker-pause state across restarts Worker pause states can optionally survive app restarts. A new toggle in Settings saves the current pause map to settings/worker_pauses.json; on startup lib.rs restores it before workers are spawned. Backend: new snapshot/replace helpers in indexer.rs, persist functions in commands.rs (get/set_worker_pauses_persist). Frontend: workerPausesPersist store field, load/setWorkerPausesPersist actions, toggle in SettingsModal. --- src-tauri/src/commands.rs | 67 ++++++++++++++++++++++++++++++++ src-tauri/src/indexer.rs | 45 ++++++++++++++++++++- src-tauri/src/lib.rs | 3 ++ src/App.tsx | 2 + src/components/SettingsModal.tsx | 15 +++++++ src/dev/mockBackend.ts | 2 + src/store.ts | 18 +++++++++ 7 files changed, 151 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 445f10c..7c0d70d 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1947,6 +1947,7 @@ pub struct FolderWorkerStates { #[tauri::command] pub async fn set_worker_paused( + app: AppHandle, db: State<'_, DbState>, worker: String, folder_id: i64, @@ -1963,6 +1964,9 @@ pub async fn set_worker_paused( db::requeue_processing_tagging_jobs_for_folder(&conn, folder_id) .map_err(|e| e.to_string())?; } + if let Err(error) = persist_worker_pauses_if_enabled(&app) { + log::warn!("Failed to persist worker pause state: {error}"); + } Ok(()) } @@ -2482,6 +2486,8 @@ pub async fn bulk_remove_tag( const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt"; const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt"; +const WORKER_PAUSES_PERSIST_FILE: &str = "settings/worker_pauses_persist.txt"; +const WORKER_PAUSES_FILE: &str = "settings/worker_pauses.json"; #[derive(Deserialize)] pub struct SetTaggingQueueScopeParams { @@ -2553,6 +2559,67 @@ pub async fn set_tagging_queue_folder_ids( Ok(()) } +fn worker_pause_persistence_enabled(app_dir: &Path) -> bool { + std::fs::read_to_string(app_dir.join(WORKER_PAUSES_PERSIST_FILE)) + .map(|value| value.trim() == "true") + .unwrap_or(false) +} + +fn write_worker_pause_snapshot(app_dir: &Path) -> Result<(), String> { + let path = app_dir.join(WORKER_PAUSES_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let json = serde_json::to_string_pretty(&indexer::snapshot_worker_paused_states()) + .map_err(|e| e.to_string())?; + std::fs::write(path, json).map_err(|e| e.to_string()) +} + +fn persist_worker_pauses_if_enabled(app: &AppHandle) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + if worker_pause_persistence_enabled(&app_dir) { + write_worker_pause_snapshot(&app_dir)?; + } + Ok(()) +} + +pub fn restore_persisted_worker_pauses(app_dir: &Path) { + if !worker_pause_persistence_enabled(app_dir) { + return; + } + + let path = app_dir.join(WORKER_PAUSES_FILE); + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + match serde_json::from_str::(&content) { + Ok(states) => indexer::replace_worker_paused_states(states), + Err(error) => log::warn!("Failed to restore persisted worker pauses: {error}"), + } +} + +#[tauri::command] +pub async fn get_worker_pauses_persist(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + Ok(worker_pause_persistence_enabled(&app_dir)) +} + +#[tauri::command] +pub async fn set_worker_pauses_persist(app: AppHandle, persist: bool) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(WORKER_PAUSES_PERSIST_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::write(path, if persist { "true" } else { "false" }).map_err(|e| e.to_string())?; + if persist { + write_worker_pause_snapshot(&app_dir)?; + } else { + let _ = std::fs::remove_file(app_dir.join(WORKER_PAUSES_FILE)); + } + Ok(()) +} + // --------------------------------------------------------------------------- // App data folder // --------------------------------------------------------------------------- diff --git a/src-tauri/src/indexer.rs b/src-tauri/src/indexer.rs index 28dab25..8e3f1be 100644 --- a/src-tauri/src/indexer.rs +++ b/src-tauri/src/indexer.rs @@ -9,7 +9,7 @@ use crate::vector; use anyhow::Result; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use rayon::prelude::*; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; @@ -41,6 +41,14 @@ struct PausedWorkerFolders { tagging: HashSet, } +#[derive(Default, Deserialize, Serialize)] +pub struct PersistedPausedWorkerFolders { + pub thumbnail: Vec, + pub metadata: Vec, + pub embedding: Vec, + pub tagging: Vec, +} + #[derive(Clone, Copy)] pub struct FolderWorkerPausedState { pub thumbnail: bool, @@ -50,6 +58,41 @@ pub struct FolderWorkerPausedState { pub tagging: bool, } +pub fn replace_worker_paused_states(states: PersistedPausedWorkerFolders) { + if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS + .get_or_init(|| Mutex::new(PausedWorkerFolders::default())) + .lock() + { + paused_folders.thumbnail = states.thumbnail.into_iter().collect(); + paused_folders.metadata = states.metadata.into_iter().collect(); + paused_folders.embedding = states.embedding.into_iter().collect(); + paused_folders.caption = HashSet::new(); + paused_folders.tagging = states.tagging.into_iter().collect(); + } +} + +pub fn snapshot_worker_paused_states() -> PersistedPausedWorkerFolders { + let Ok(paused_folders) = PAUSED_WORKER_FOLDERS + .get_or_init(|| Mutex::new(PausedWorkerFolders::default())) + .lock() + else { + return PersistedPausedWorkerFolders::default(); + }; + + let sorted = |set: &HashSet| { + let mut ids = set.iter().copied().collect::>(); + ids.sort_unstable(); + ids + }; + + PersistedPausedWorkerFolders { + thumbnail: sorted(&paused_folders.thumbnail), + metadata: sorted(&paused_folders.metadata), + embedding: sorted(&paused_folders.embedding), + tagging: sorted(&paused_folders.tagging), + } +} + pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) { if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS .get_or_init(|| Mutex::new(PausedWorkerFolders::default())) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5d167b..6c51442 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -119,6 +119,7 @@ pub fn run() { let thumb_dir = app_dir.join("thumbnails"); std::fs::create_dir_all(&thumb_dir).expect("Failed to create thumbnail dir"); + commands::restore_persisted_worker_pauses(&app_dir); // The asset protocol scope is no longer a blanket "**": thumbnails // are allowed statically in tauri.conf.json, and each indexed @@ -195,6 +196,8 @@ pub fn run() { commands::suggest_image_tags, commands::set_worker_paused, commands::get_worker_states, + commands::get_worker_pauses_persist, + commands::set_worker_pauses_persist, commands::get_tag_cloud, commands::get_explore_tags, commands::get_related_tags, diff --git a/src/App.tsx b/src/App.tsx index 75de078..9a66c3f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -27,6 +27,7 @@ export default function App() { const loadAlbums = useGalleryStore((state) => state.loadAlbums); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadNotificationsPaused = useGalleryStore((state) => state.loadNotificationsPaused); + const loadWorkerPausesPersist = useGalleryStore((state) => state.loadWorkerPausesPersist); const subscribeToProgress = useGalleryStore((state) => state.subscribeToProgress); const loadAppVersion = useGalleryStore((state) => state.loadAppVersion); const checkForUpdates = useGalleryStore((state) => state.checkForUpdates); @@ -39,6 +40,7 @@ export default function App() { void initializeNotifications(); void loadMutedFolderIds(); void loadNotificationsPaused(); + void loadWorkerPausesPersist(); void loadFfmpegStatus(); void loadOnboardingCompleted(); // Load the app version first so the What's New toast/modal (which read diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index c36dc30..c9a2a41 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -228,6 +228,8 @@ export function SettingsModal() { const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); + const workerPausesPersist = useGalleryStore((state) => state.workerPausesPersist); + const setWorkerPausesPersist = useGalleryStore((state) => state.setWorkerPausesPersist); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); const rebuildSemanticIndex = useGalleryStore((state) => state.rebuildSemanticIndex); @@ -854,6 +856,19 @@ export function SettingsModal() { + + + diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index a4d8ddd..7bd8fd1 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -381,9 +381,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "set_tagging_queue_folder_ids": case "set_muted_folder_ids": case "set_notifications_paused": + case "set_worker_pauses_persist": case "set_worker_paused": return null; case "get_notifications_paused": + case "get_worker_pauses_persist": case "get_onboarding_completed": return db.scenario !== "empty"; case "set_onboarding_completed": diff --git a/src/store.ts b/src/store.ts index e1c9bb3..72ab5a7 100644 --- a/src/store.ts +++ b/src/store.ts @@ -400,6 +400,7 @@ interface GalleryState { taggingQueueFolderIds: number[]; mutedFolderIds: number[]; notificationsPaused: boolean; + workerPausesPersist: boolean; theme: AppTheme; lightboxAutoplay: boolean; lightboxAutoMute: boolean; @@ -524,6 +525,8 @@ interface GalleryState { toggleMutedFolder: (folderId: number) => void; loadNotificationsPaused: () => Promise; setNotificationsPaused: (paused: boolean) => void; + loadWorkerPausesPersist: () => Promise; + setWorkerPausesPersist: (persist: boolean) => void; setTheme: (theme: AppTheme) => void; setLightboxAutoplay: (enabled: boolean) => void; setLightboxAutoMute: (enabled: boolean) => void; @@ -889,6 +892,7 @@ export const useGalleryStore = create((set, get) => ({ taggingQueueFolderIds: [], mutedFolderIds: [], notificationsPaused: false, + workerPausesPersist: false, theme: initialTheme(), lightboxAutoplay: initialBoolSetting(LIGHTBOX_AUTOPLAY_KEY, true), lightboxAutoMute: initialBoolSetting(LIGHTBOX_AUTO_MUTE_KEY, false), @@ -1921,6 +1925,20 @@ export const useGalleryStore = create((set, get) => ({ return entries; }, + loadWorkerPausesPersist: async () => { + try { + const persist = await invoke("get_worker_pauses_persist"); + set({ workerPausesPersist: persist }); + } catch { + // fall back to in-memory default + } + }, + + setWorkerPausesPersist: (persist) => { + set({ workerPausesPersist: persist }); + void invoke("set_worker_pauses_persist", { persist }).catch(() => {}); + }, + setTheme: (theme) => { window.localStorage.setItem(THEME_KEY, theme); document.documentElement.dataset.theme = theme; From 9144be2518817b29f9491afab1bd19168fdc4a18 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 16:28:35 +0100 Subject: [PATCH 07/13] feat: Tooltip portal with anchorToCursor mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooltip now portals cursor-anchored variants into document.body via a `mounted` guard, preventing transformed parents and scroll-container overflow from distorting coordinates. New `anchorToCursor` prop locks position at hover entry without tracking; `followCursor` retains the spring-animated tracking behaviour. Color swatches (ColorFilter), timeline scrubber dots/labels (Timeline), and toolbar dropdowns (Toolbar) are updated to use the appropriate cursor mode. Toolbar z-index bumped z-20→z-40 (dropdowns z-30→z-50) to layer above portaled content; tag autocomplete result guard added (Array.isArray). --- src/components/ColorFilter.tsx | 12 ++++---- src/components/Timeline.tsx | 37 ++++++++++++----------- src/components/Toolbar.tsx | 45 ++++++++++++++-------------- src/components/Tooltip.tsx | 55 ++++++++++++++++++++-------------- 4 files changed, 81 insertions(+), 68 deletions(-) diff --git a/src/components/ColorFilter.tsx b/src/components/ColorFilter.tsx index f4bcbe9..1cca85b 100644 --- a/src/components/ColorFilter.tsx +++ b/src/components/ColorFilter.tsx @@ -56,7 +56,7 @@ export function ColorFilter() { }, [open]); return ( -
+
{/* Trigger — a single palette icon; shows the active color as a dot when a filter is applied so the collapsed state still communicates it. */} @@ -99,9 +99,9 @@ export function ColorFilter() { {SWATCHES.map((swatch) => { const active = rgbEquals(colorFilter, swatch.rgb); return ( +
{isActive || (colorBackfill && colorBackfill.total > 0) ? ( -
+
{colorBackfill && colorBackfill.total > 0 ? ( - - + + +
; } return ( -
diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index f2595fa..90ef087 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -3,6 +3,7 @@ import { invoke } from "@tauri-apps/api/core"; import { tileSizeForZoom, useGalleryStore, SortOrder, MediaFilter, SearchCommand, parseSearchValue, searchModeLabel, ExploreTagEntry } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; import { ColorFilter } from "./ColorFilter"; +import { Tooltip } from "./Tooltip"; const BASE_SORT_OPTIONS: { value: SortOrder; label: string }[] = [ { value: "date_desc", label: "Newest first" }, @@ -230,7 +231,7 @@ export function Toolbar() { const results = await invoke("search_tags_autocomplete", { params: { query: searchQuery.trim(), folder_id: selectedFolderId ?? null, limit: 10 }, }); - setTagSuggestions(results); + setTagSuggestions(Array.isArray(results) ? results : []); } catch { setTagSuggestions([]); } @@ -266,7 +267,7 @@ export function Toolbar() { const showCommandHints = !searchCommand && searchPanelOpen; return ( -
+
{/* Primary row */}
{/* Title + count */} @@ -357,7 +358,7 @@ export function Toolbar() { {/* Tag autocomplete suggestions */} {showTagSuggestions && tagSuggestions.length > 0 ? ( -
+
{tagSuggestions.map((entry) => ( + + + ))}
diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index c345c4a..6aee866 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -1,4 +1,5 @@ import { MouseEvent, ReactNode, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { motion } from "framer-motion"; type TooltipSide = "top" | "bottom" | "left" | "right"; @@ -20,8 +21,8 @@ const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`; /** * Lightweight custom tooltip — fades in (faster than the native one) after a * tunable hover `delay`, and hides instantly on leave or click. By default it - * anchors to a `side` of the wrapper; with `followCursor` it tracks the pointer - * (fixed-positioned, so it escapes scroll-container clipping). + * anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's + * entry position; `followCursor` keeps tracking the pointer. */ export function Tooltip({ label, @@ -29,6 +30,7 @@ export function Tooltip({ side = "bottom", align = "center", block = false, + anchorToCursor = false, followCursor = false, className = "", children, @@ -41,6 +43,8 @@ export function Tooltip({ align?: TooltipAlign; /** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */ block?: boolean; + /** Position at the cursor when shown, without following subsequent movement. */ + anchorToCursor?: boolean; /** Track the cursor (fixed position) instead of anchoring to a side. */ followCursor?: boolean; /** Extra classes for the wrapper (e.g. layout). */ @@ -49,6 +53,7 @@ export function Tooltip({ }) { const [visible, setVisible] = useState(false); const [coords, setCoords] = useState({ x: 0, y: 0 }); + const [mounted, setMounted] = useState(false); const timer = useRef | undefined>(undefined); const frame = useRef(undefined); const tooltipRef = useRef(null); @@ -79,7 +84,7 @@ export function Tooltip({ }; const show = (event: MouseEvent) => { clear(); - if (followCursor) place(event.clientX, event.clientY); + if (anchorToCursor || followCursor) place(event.clientX, event.clientY); if (delay <= 0) { setVisible(true); return; @@ -100,9 +105,31 @@ export function Tooltip({ }); }; - useEffect(() => clear, []); + useEffect(() => { + setMounted(true); + return clear; + }, []); const Wrapper = block ? "div" : "span"; + const cursorTooltip = ( + + {label} + + ); return ( {children} - {followCursor ? ( - - {label} - + {anchorToCursor || followCursor ? ( + mounted ? createPortal(cursorTooltip, document.body) : null ) : ( Date: Mon, 29 Jun 2026 16:29:12 +0100 Subject: [PATCH 08/13] feat: virtualized tag manager with filter, sort, and light-theme support Replaces the flat TagManageRow list with a virtualized grid of TagManageTile cards using @tanstack/react-virtual and dynamic measured heights (46px idle, 82px when editing/confirming). Adds a filter input and a sort dropdown (most-used / least-used / A-Z / Z-A). renameTag and deleteTag no longer clear exploreTagEntries on invalidation so the manager keeps its filter/sort state during the background refresh. Light-theme overrides cover all new tag-manager class names. --- src/components/TagCloud.tsx | 238 +++++++++++++++++++++++++++++------- src/dev/mockBackend.ts | 16 ++- src/index.css | 101 +++++++++++++++ src/store.ts | 6 +- 4 files changed, 312 insertions(+), 49 deletions(-) diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index 9b9002a..b2077ba 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { ExploreMode, ExploreTagEntry, RelatedTagEntry, TagCloudEntry, useGalleryStore } from "../store"; import { FolderScopeDropdown } from "./FolderScopeDropdown"; +import { ThemedDropdown } from "./ThemedDropdown"; import { Tooltip } from "./Tooltip"; import { mediaSrc } from "../lib/mediaSrc"; @@ -666,9 +668,18 @@ function ClusterCloud({ ); } -// A flat, manageable row for a single tag — rename (which doubles as merge when -// the new name already exists) and delete across the whole library. -function TagManageRow({ +type TagManageSort = "count_desc" | "count_asc" | "az" | "za"; + +const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ + { value: "count_desc", label: "Most used" }, + { value: "count_asc", label: "Least used" }, + { value: "az", label: "A-Z" }, + { value: "za", label: "Z-A" }, +]; + +// Compact management tile for a single tag. Rename doubles as merge when the new +// name already exists, and delete applies across the scoped tag set. +function TagManageTile({ entry, onSearch, onRename, @@ -708,12 +719,17 @@ function TagManageRow({ }; return ( -
-
+
+
{editing ? ( setValue(e.target.value)} onKeyDown={(e) => { @@ -723,29 +739,31 @@ function TagManageRow({ disabled={busy} /> ) : ( - + + + )} + + {entry.count.toLocaleString()} +
- {entry.count.toLocaleString()} {editing ? ( -
+
) : confirming ? ( -
+
) : ( -
+
+ + + - + + + ) : null} +
+ setSort(value as TagManageSort)} + options={TAG_MANAGE_SORTS} + ariaLabel="Sort managed tags" + align="right" + /> +
+
+
+ +
+
+ {filteredEntries.length === 0 ? ( +
+ No tags match that filter. +
+ ) : ( +
+ {visibleItems.map((virtualRow) => { + const start = virtualRow.index * columns; + const rowEntries = filteredEntries.slice(start, start + columns); + return ( +
+ {rowEntries.map((entry) => ( + + ))} +
+ ); + })} +
+ )} +
); @@ -864,6 +1010,8 @@ export function TagCloud() { : hasEntries ? exploreMode === "visual" ? `${entryCount} cluster${entryCount !== 1 ? "s" : ""} — click any to open` + : manageTags + ? `${entryCount} tag${entryCount !== 1 ? "s" : ""} available to manage` : visibleTagCount < entryCount ? `${visibleTagCount} of ${entryCount} tags shown — click any to search` : `${entryCount} tag${entryCount !== 1 ? "s" : ""} — click any to search` diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 7bd8fd1..ef0c7eb 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -1,5 +1,5 @@ import { emit } from "@tauri-apps/api/event"; -import type { Album, Folder, ImageRecord, ImageTag, SortOrder } from "../store"; +import type { Album, ExploreTagEntry, Folder, ImageRecord, ImageTag, SortOrder } from "../store"; import { compareImages, createMockDb, mockExif } from "./mockFixtures"; import { getMockScenario } from "./mockScenarios"; @@ -58,12 +58,22 @@ function searchTags(payload: unknown) { .filter(([, tags]) => tags.some((tag) => tag.tag.toLowerCase().includes(query))) .map(([imageId]) => Number(imageId)), ); - const images = filterImages(db.images, payload) + const images = filterImages(db.images, { params: { ...p, query: "", search: "" } }) .filter((image) => matchingIds.has(image.id)) .sort(compareImages((p.sort ?? "date_desc") as SortOrder)); return page(images, Number(p.offset ?? 0), Number(p.limit ?? 200)); } +function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] { + const p = params(payload); + const query = String(p.query ?? "").trim().toLowerCase(); + const limit = Number(p.limit ?? 10); + return db.exploreTags + .filter((entry) => !query || entry.tag.toLowerCase().includes(query)) + .sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)) + .slice(0, limit); +} + function semanticSearch(payload: unknown): ImageRecord[] { const p = params(payload); const query = String(p.query ?? "").toLowerCase(); @@ -233,6 +243,8 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise return semanticSearch(payload); case "search_images_by_tag": return searchTags(payload); + case "search_tags_autocomplete": + return searchTagsAutocomplete(payload); case "get_images_by_ids": return (p.image_ids ?? []).map((id: number) => db.images.find((image) => image.id === id)).filter(Boolean); case "find_similar_images": diff --git a/src/index.css b/src/index.css index e573b50..d79b1dd 100644 --- a/src/index.css +++ b/src/index.css @@ -218,6 +218,107 @@ html[data-theme="subtle-light"] .explore-spinner { border-top-color: rgb(17 24 39 / 0.55) !important; } +html[data-theme="subtle-light"] .tag-manager-header { + background: rgb(244 242 234 / 0.72) !important; + border-color: #d8d2c7 !important; +} + +html[data-theme="subtle-light"] .tag-manager-stat { + background: rgb(31 41 55 / 0.06) !important; + color: #5f5a52 !important; +} + +html[data-theme="subtle-light"] .tag-manager-match { + background: rgb(37 99 235 / 0.12) !important; + color: #1d4ed8 !important; +} + +html[data-theme="subtle-light"] .tag-manager-help, +html[data-theme="subtle-light"] .tag-manager-empty { + color: #6f6a61 !important; +} + +html[data-theme="subtle-light"] .tag-manager-filter { + background: #fbfaf6 !important; + border-color: #cfc7b8 !important; + color: #111827 !important; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.64) !important; +} + +html[data-theme="subtle-light"] .tag-manager-filter::placeholder { + color: #8a8378 !important; +} + +html[data-theme="subtle-light"] .tag-manager-filter:focus { + background: #fffdfa !important; + border-color: #7aa2e3 !important; +} + +html[data-theme="subtle-light"] .tag-manager-clear { + color: #6b645a !important; +} + +html[data-theme="subtle-light"] .tag-manager-clear:hover { + background: rgb(31 41 55 / 0.08) !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-tile { + background: rgb(251 250 246 / 0.5) !important; + border-color: rgb(151 141 126 / 0.22) !important; +} + +html[data-theme="subtle-light"] .tag-manager-tile:hover, +html[data-theme="subtle-light"] .tag-manager-tile:focus-within { + background: rgb(255 253 250 / 0.74) !important; + border-color: rgb(151 141 126 / 0.36) !important; +} + +html[data-theme="subtle-light"] .tag-manager-name { + color: #1f2937 !important; +} + +html[data-theme="subtle-light"] .tag-manager-name:hover { + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-count { + background: rgb(31 41 55 / 0.07) !important; + color: #6b645a !important; +} + +html[data-theme="subtle-light"] .tag-manager-edit-input { + background: #fffdfa !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-secondary, +html[data-theme="subtle-light"] .tag-manager-action { + background: rgb(255 253 250 / 0.92) !important; + border-color: rgb(31 41 55 / 0.12) !important; + color: #5f5a52 !important; +} + +html[data-theme="subtle-light"] .tag-manager-secondary:hover, +html[data-theme="subtle-light"] .tag-manager-action:hover { + background: rgb(31 41 55 / 0.06) !important; + color: #111827 !important; +} + +html[data-theme="subtle-light"] .tag-manager-save { + color: #1d4ed8 !important; +} + +html[data-theme="subtle-light"] .tag-manager-danger, +html[data-theme="subtle-light"] .tag-manager-action-danger:hover { + color: #b91c1c !important; +} + +html[data-theme="subtle-light"] .tag-manager-empty { + background: rgb(251 250 246 / 0.42) !important; + border-color: #d8d2c7 !important; +} + html[data-theme="subtle-light"] .feature-scope-trigger { background: #f8f6ef !important; border-color: #d0c8ba !important; diff --git a/src/store.ts b/src/store.ts index 72ab5a7..aec0a3a 100644 --- a/src/store.ts +++ b/src/store.ts @@ -2370,9 +2370,10 @@ export const useGalleryStore = create((set, get) => ({ renameTag: async (from, to) => { await invoke("rename_tag", { params: { from, to } }); // Tag content changed — invalidate the explore-tags and tag-cloud caches. + // Keep the current tag list visible while the refresh runs so manager UI + // state such as filtering and sorting is not lost to a loading remount. set({ exploreTagsFolderId: undefined, - exploreTagEntries: [], tagCloudFolderId: undefined, tagCloudEntries: [], }); @@ -2388,9 +2389,10 @@ export const useGalleryStore = create((set, get) => ({ deleteTag: async (tag) => { const removed = await invoke("delete_tag", { params: { tag } }); + // Keep the current tag list visible while the refresh runs so manager UI + // state such as filtering and sorting is not lost to a loading remount. set({ exploreTagsFolderId: undefined, - exploreTagEntries: [], tagCloudFolderId: undefined, tagCloudEntries: [], }); From 5bc397af01cfda151ab655c9f178a73d8c8aaa03 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 16:30:05 +0100 Subject: [PATCH 09/13] chore: add Playwright test scaffolding Adds @playwright/test and @types/node as dev dependencies, playwright.config.ts with a localhost:1420 base URL targeting the Vite dev server, an example spec, and the standard Playwright output directories to .gitignore. --- .gitignore | 9 ++++- package.json | 2 ++ playwright.config.ts | 79 +++++++++++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 82 +++++++++++++++++++++++++++++++++++-------- tests/example.spec.ts | 18 ++++++++++ 5 files changed, 175 insertions(+), 15 deletions(-) create mode 100644 playwright.config.ts create mode 100644 tests/example.spec.ts diff --git a/.gitignore b/.gitignore index cfff0f4..ecec16c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,12 @@ dist-ssr *.pyc # Bundled CUDA runtime DLLs for the CUDA build (copied from the toolkit -# locally; ~600 MB, never committed). See RELEASE_PLAN.md "CUDA release variant". +# locally; ~600 MB, never committed). src-tauri/cuda-redist/ + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ +/playwright/.auth/ diff --git a/package.json b/package.json index d3d03e3..3697e06 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,11 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/cli": "^2", "@types/d3-force": "^3.0.10", + "@types/node": "^26.0.1", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..6dfc0d9 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,79 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// import dotenv from 'dotenv'; +// import path from 'path'; +// dotenv.config({ path: path.resolve(__dirname, '.env') }); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://localhost:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5dd8c70..8762e9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,15 +48,21 @@ importers: specifier: ^5.0.12 version: 5.0.12(@types/react@19.2.14)(react@19.2.4) devDependencies: + '@playwright/test': + specifier: ^1.61.1 + version: 1.61.1 '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) '@tauri-apps/cli': specifier: ^2 version: 2.10.1 '@types/d3-force': specifier: ^3.0.10 version: 3.0.10 + '@types/node': + specifier: ^26.0.1 + version: 26.0.1 '@types/react': specifier: ^19.1.8 version: 19.2.14 @@ -65,7 +71,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -74,7 +80,7 @@ importers: version: 5.8.3 vite: specifier: ^7.0.4 - version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) website: dependencies: @@ -96,7 +102,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) '@types/react': specifier: ^19.1.8 version: 19.2.14 @@ -105,7 +111,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) tailwindcss: specifier: ^4.2.2 version: 4.2.2 @@ -114,10 +120,10 @@ importers: version: 5.8.3 vite: specifier: ^7.0.4 - version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + version: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) vite-imagetools: specifier: ^10.0.0 - version: 10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 10.0.0(rollup@4.60.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)) packages: @@ -538,6 +544,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -906,6 +917,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/node@26.0.1': + resolution: {integrity: sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1010,6 +1024,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1147,6 +1166,16 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + postcss@8.5.8: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} @@ -1208,6 +1237,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -1597,6 +1629,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/pluginutils@5.4.0(rollup@4.60.1)': @@ -1743,12 +1779,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) '@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -1856,6 +1892,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/node@26.0.1': + dependencies: + undici-types: 8.3.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -1864,7 +1904,7 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitejs/plugin-react@4.7.0(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -1872,7 +1912,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color @@ -1963,6 +2003,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -2053,6 +2096,14 @@ snapshots: picomatch@4.0.4: {} + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + postcss@8.5.8: dependencies: nanoid: 3.3.11 @@ -2151,22 +2202,24 @@ snapshots: typescript@5.8.3: {} + undici-types@8.3.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 - vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)): + vite-imagetools@10.0.0(rollup@4.60.1)(vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0)): dependencies: '@rollup/pluginutils': 5.4.0(rollup@4.60.1) imagetools-core: 9.1.0 sharp: 0.34.5 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0) transitivePeerDependencies: - rollup - vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): + vite@7.3.1(@types/node@26.0.1)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -2175,6 +2228,7 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 26.0.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 diff --git a/tests/example.spec.ts b/tests/example.spec.ts new file mode 100644 index 0000000..54a906a --- /dev/null +++ b/tests/example.spec.ts @@ -0,0 +1,18 @@ +import { test, expect } from '@playwright/test'; + +test('has title', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Expect a title "to contain" a substring. + await expect(page).toHaveTitle(/Playwright/); +}); + +test('get started link', async ({ page }) => { + await page.goto('https://playwright.dev/'); + + // Click the get started link. + await page.getByRole('link', { name: 'Get started' }).click(); + + // Expects page to have a heading with the name of Installation. + await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible(); +}); From 8dbabc2d9e44303b25275d55a93ecb4cdc06fb65 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 16:50:40 +0100 Subject: [PATCH 10/13] fix: match tag cloud hover glow to dark mode in light theme Replace the flat background hover override with a dark warm radial gradient on the ::before pseudo-element, mirroring the same elliptical glow effect used in dark mode. --- src/index.css | 9 +++++++-- website/package.json | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/index.css b/src/index.css index d79b1dd..c353603 100644 --- a/src/index.css +++ b/src/index.css @@ -209,8 +209,13 @@ html[data-theme="subtle-light"] .explore-cluster-count { color: #ffffff !important; } -html[data-theme="subtle-light"] .explore-tag-word:hover { - background: #e8e2d6 !important; +html[data-theme="subtle-light"] .explore-tag-word::before { + background: radial-gradient( + ellipse at center, + rgba(60, 50, 30, 0.13), + rgba(60, 50, 30, 0.05) 46%, + rgba(60, 50, 30, 0) 72% + ) !important; } html[data-theme="subtle-light"] .explore-spinner { diff --git a/website/package.json b/website/package.json index 9d05845..9bc7cf1 100644 --- a/website/package.json +++ b/website/package.json @@ -4,8 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", "build": "tsc && vite build", + "dev": "vite", "preview": "vite preview", "typecheck": "tsc --noEmit" }, From f2939d70abaff467755cb1b93a26760ab28a96c4 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Mon, 29 Jun 2026 17:23:03 +0100 Subject: [PATCH 11/13] fix: atlas glow and AI Workspace scope pre-selection in light theme Two targeted fixes: - Tag Cloud atlas SVG radial gradient now switches its inner stop from white to a warm dark tone (rgba 60 50 30) when the Subtle Light theme is active, matching the explore-tag hover glow style used elsewhere in light mode. - Switching the AI Workspace tagging queue scope to "Selected Folders" no longer auto-selects the first folder in the list; the selection now starts empty so the user can choose exactly which folders to target. --- src/components/TagCloud.tsx | 4 ++-- src/store.ts | 12 ++---------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx index b2077ba..c38026a 100644 --- a/src/components/TagCloud.tsx +++ b/src/components/TagCloud.tsx @@ -477,8 +477,8 @@ function TagAtlas({