Compare commits

...

8 Commits

Author SHA1 Message Date
LyAhn 3ab9357d6f perf(explore): reduce tag cloud refresh pressure
github/actions/ci GitHub Actions CI finished: success
Debounce Explore tag refreshes while AI tagging is active so the tag cloud catches up after worker activity settles instead of continuously re-querying.

Optimize the tag cloud aggregate query by fetching representative thumbnails in one pass and adding a supporting tag/image index.
2026-07-03 22:43:41 +01:00
LyAhn fe65bc6f38 feat(settings): reorganize preferences pages
Split Settings into General, Media, Updates & Setup, Storage, and AI Workspace pages so update/setup and maintenance controls are easier to reach.

Move runtime check results beside the model runtime controls instead of under model location.
2026-07-03 22:43:41 +01:00
LyAhn 68932b55c5 fix(tagger): separate JoyTag confidence threshold
Store confidence thresholds per tagger model so JoyTag no longer inherits WD tuning. Refresh the active threshold when switching models, guard stale threshold saves, and keep UI Lab mocks in sync.

Also tightens the onboarding model selector so the segmented control no longer stretches across the row.
2026-07-03 22:43:41 +01:00
LyAhn f1116c6c26 feat(onboarding): choose AI tagger model
Add WD and JoyTag selection to the Welcome Tour AI step so users can choose the model before downloading it.

Share tagger model metadata with Settings and keep the Settings close button anchored to the modal chrome.
2026-07-03 22:43:41 +01:00
LyAhn b92b850d02 Merge AI tagger readiness fixes
Refresh selected tagger readiness at startup so the lightbox AI tags action reflects installed model state without needing a Settings refresh.

Add UI Lab coverage for uninstalled WD and JoyTag scenarios, and include the toolbar clear-button alignment follow-up.
2026-07-02 20:59:48 +01:00
LyAhn bf04df7484 fix(toolbar): center search field overlay buttons
The clear button and command-prefix chip sat a few pixels high: their
inline-flex Tooltip wrappers created a line box with descender space
below, making the positioned div taller than the button it centers.
Making the wrappers flex containers collapses them to the button height
so the translate centering lands correctly.
2026-07-02 20:47:22 +01:00
LyAhn d29a779c13 test(ui-lab): add tagger readiness scenarios
Add UI Lab scenarios for uninstalled WD and JoyTag tagger states so the lightbox and AI Workspace readiness flows can be exercised directly.

Make the Settings title-bar button accessible by label to support reliable UI Lab automation.
2026-07-02 20:22:06 +01:00
LyAhn b7e82dbf91 fix(ai-tags): refresh tagger readiness for lightbox
Load the selected tagger model and model status during app startup so the lightbox AI tags action does not stay disabled until Settings refreshes the state.

Refresh readiness after tagger downloads complete and replace stale WD-specific unavailable copy with generic AI tagger wording.
2026-07-02 20:13:45 +01:00
14 changed files with 440 additions and 164 deletions
+3
View File
@@ -63,6 +63,9 @@ aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
so the info panel shows more at a glance with less scrolling. so the info panel shows more at a glance with less scrolling.
- **Faster Explore revisits** — returning to a folder's visual clusters should - **Faster Explore revisits** — returning to a folder's visual clusters should
feel much faster now, even in big libraries. feel much faster now, even in big libraries.
- **Calmer Tag Cloud during AI tagging** — Explore no longer keeps hammering the
tag list while a folder is actively being tagged, so tagging stays smoother and
the cloud catches up once the work settles.
- **Faster first-time clustering** — large libraries build their first visual - **Faster first-time clustering** — large libraries build their first visual
clusters much more quickly, while still keeping the groups nicely balanced. clusters much more quickly, while still keeping the groups nicely balanced.
- **Better tag browsing** — the Tag manager now has live search, sorting - **Better tag browsing** — the Tag manager now has live search, sorting
+6 -1
View File
@@ -2159,6 +2159,7 @@ pub struct SetTaggerModelParams {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SetTaggerThresholdParams { pub struct SetTaggerThresholdParams {
pub threshold: f32, pub threshold: f32,
pub model: Option<TaggerModel>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -2258,7 +2259,11 @@ pub async fn set_tagger_threshold(
params: SetTaggerThresholdParams, params: SetTaggerThresholdParams,
) -> Result<f32, String> { ) -> Result<f32, String> {
let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
tagger::set_tagger_threshold(&app_dir, params.threshold).map_err(|e| e.to_string()) match params.model {
Some(model) => tagger::set_tagger_threshold_for_model(&app_dir, model, params.threshold),
None => tagger::set_tagger_threshold(&app_dir, params.threshold),
}
.map_err(|e| e.to_string())
} }
#[tauri::command] #[tauri::command]
+27 -16
View File
@@ -303,6 +303,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id); CREATE INDEX IF NOT EXISTS idx_image_tags_image_id ON image_tags(image_id);
CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source); CREATE INDEX IF NOT EXISTS idx_image_tags_source ON image_tags(source);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag); CREATE INDEX IF NOT EXISTS idx_image_tags_tag ON image_tags(tag);
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_image_id ON image_tags(tag, image_id);
CREATE TABLE IF NOT EXISTS albums ( CREATE TABLE IF NOT EXISTS albums (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -2446,32 +2447,42 @@ pub fn get_explore_tags(
limit: usize, limit: usize,
) -> Result<Vec<ExploreTagEntry>> { ) -> Result<Vec<ExploreTagEntry>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, "WITH tag_counts AS (
MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, SELECT t.tag,
MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source COUNT(DISTINCT t.image_id) AS tag_count,
FROM image_tags t MIN(t.image_id) AS representative_image_id,
JOIN images i ON i.id = t.image_id MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source,
WHERE (?1 IS NULL OR i.folder_id = ?1) MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source
GROUP BY t.tag FROM image_tags t
HAVING COUNT(DISTINCT t.image_id) >= 1 JOIN images i ON i.id = t.image_id
ORDER BY tag_count DESC, t.tag ASC WHERE (?1 IS NULL OR i.folder_id = ?1)
LIMIT ?2", GROUP BY t.tag
HAVING COUNT(DISTINCT t.image_id) >= 1
ORDER BY tag_count DESC, t.tag ASC
LIMIT ?2
)
SELECT c.tag,
c.tag_count,
c.representative_image_id,
i.thumbnail_path,
c.has_ai_source,
c.has_user_source
FROM tag_counts c
JOIN images i ON i.id = c.representative_image_id
ORDER BY c.tag_count DESC, c.tag ASC",
)?; )?;
let rows = stmt let rows = stmt
.query_map(params![folder_id, limit as i64], |row| { .query_map(params![folder_id, limit as i64], |row| {
let representative_image_id = row.get::<_, i64>(2)?; let representative_image_id = row.get::<_, i64>(2)?;
let thumbnail_path = get_image_by_id(conn, representative_image_id)
.ok()
.and_then(|image| image.thumbnail_path);
Ok(ExploreTagEntry { Ok(ExploreTagEntry {
tag: row.get(0)?, tag: row.get(0)?,
count: row.get(1)?, count: row.get(1)?,
representative_image_id, representative_image_id,
thumbnail_path, thumbnail_path: row.get(3)?,
has_ai_source: row.get::<_, i64>(3)? != 0, has_ai_source: row.get::<_, i64>(4)? != 0,
has_user_source: row.get::<_, i64>(4)? != 0, has_user_source: row.get::<_, i64>(5)? != 0,
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
+40 -20
View File
@@ -16,6 +16,7 @@ pub const WD_TAGGER_MODEL_NAME: &str = "wd-swinv2-tagger-v3";
const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt"; const TAGGER_ACCELERATION_FILE: &str = "settings/tagger_acceleration.txt";
const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt"; const TAGGER_THRESHOLD_FILE: &str = "settings/tagger_threshold.txt";
const JOYTAG_THRESHOLD_FILE: &str = "settings/joytag_threshold.txt";
const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt"; const TAGGER_BATCH_SIZE_FILE: &str = "settings/tagger_batch_size.txt";
const TAGGER_MODEL_FILE: &str = "settings/tagger_model.txt"; const TAGGER_MODEL_FILE: &str = "settings/tagger_model.txt";
@@ -27,8 +28,7 @@ pub const JOYTAG_MODEL_NAME: &str = "joytag";
// NCHW layout (not NHWC). These are the OpenAI CLIP normalization constants. // 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_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_6, 0.275_777_1]; const JOYTAG_STD: [f32; 3] = [0.268_629_54, 0.261_302_6, 0.275_777_1];
// JoyTag's recommended detection threshold (used as the default; the user's // JoyTag's recommended detection threshold.
// tagger_threshold setting still overrides it).
const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4; const JOYTAG_DEFAULT_THRESHOLD: f32 = 0.4;
// Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and // Shared ONNX Runtime DLLs (live in the caption model's `onnxruntime/` dir and
@@ -64,8 +64,8 @@ pub const TAGGER_INFER_CHUNK: usize = 4;
/// claim the GPU/CPU between dispatches. Negligible against per-chunk inference. /// claim the GPU/CPU between dispatches. Negligible against per-chunk inference.
pub const TAGGER_INFER_YIELD_MS: u64 = 40; pub const TAGGER_INFER_YIELD_MS: u64 = 40;
/// Set to `true` by `set_tagger_acceleration` so the tagging worker loop /// Set to `true` by tagger setting changes so the tagging worker loop knows to
/// knows to drop its cached `WdTagger` and reload with the new EP. /// drop its cached model session and reload with the current settings.
pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false); pub static TAGGER_SESSION_DIRTY: AtomicBool = AtomicBool::new(false);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -112,6 +112,20 @@ impl TaggerModel {
} }
} }
fn threshold_file(self) -> &'static str {
match self {
Self::Wd => TAGGER_THRESHOLD_FILE,
Self::JoyTag => JOYTAG_THRESHOLD_FILE,
}
}
fn default_threshold(self) -> f32 {
match self {
Self::Wd => DEFAULT_THRESHOLD,
Self::JoyTag => JOYTAG_DEFAULT_THRESHOLD,
}
}
/// Hugging Face repo the model files are fetched from. /// Hugging Face repo the model files are fetched from.
fn repo_id(self) -> &'static str { fn repo_id(self) -> &'static str {
match self { match self {
@@ -280,21 +294,33 @@ pub fn set_tagger_acceleration(
Ok(acceleration) Ok(acceleration)
} }
pub fn tagger_threshold(app_data_dir: &Path) -> f32 { fn tagger_threshold_for_model(app_data_dir: &Path, model: TaggerModel) -> f32 {
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE); let path = app_data_dir.join(model.threshold_file());
let Ok(value) = std::fs::read_to_string(path) else { let Ok(value) = std::fs::read_to_string(path) else {
return DEFAULT_THRESHOLD; return model.default_threshold();
}; };
value value
.trim() .trim()
.parse::<f32>() .parse::<f32>()
.unwrap_or(DEFAULT_THRESHOLD) .unwrap_or_else(|_| model.default_threshold())
.clamp(0.01, 1.0) .clamp(0.01, 1.0)
} }
pub fn tagger_threshold(app_data_dir: &Path) -> f32 {
tagger_threshold_for_model(app_data_dir, tagger_model(app_data_dir))
}
pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32> { pub fn set_tagger_threshold(app_data_dir: &Path, threshold: f32) -> Result<f32> {
set_tagger_threshold_for_model(app_data_dir, tagger_model(app_data_dir), threshold)
}
pub fn set_tagger_threshold_for_model(
app_data_dir: &Path,
model: TaggerModel,
threshold: f32,
) -> Result<f32> {
let clamped = threshold.clamp(0.01, 1.0); let clamped = threshold.clamp(0.01, 1.0);
let path = app_data_dir.join(TAGGER_THRESHOLD_FILE); let path = app_data_dir.join(model.threshold_file());
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)?;
} }
@@ -466,7 +492,8 @@ pub fn probe_tagger_runtime(app_data_dir: &Path) -> Result<TaggerRuntimeProbe> {
let status = tagger_model_status(app_data_dir); let status = tagger_model_status(app_data_dir);
if !status.ready { if !status.ready {
anyhow::bail!( anyhow::bail!(
"WD Tagger model is missing {} required file(s): {}", "{} is missing {} required file(s): {}",
status.model_name,
status.missing_files.len(), status.missing_files.len(),
status.missing_files.join(", ") status.missing_files.join(", ")
); );
@@ -1056,17 +1083,10 @@ fn load_joytag_labels(path: &Path) -> Result<Vec<String>> {
Ok(labels) Ok(labels)
} }
/// JoyTag detection threshold. Honours the shared `tagger_threshold` setting if /// JoyTag detection threshold. Uses JoyTag's own setting so WD tuning does not
/// the user has set it, otherwise uses JoyTag's recommended default (0.4). /// leak into the general/photo-friendly model.
fn joytag_threshold(app_data_dir: &Path) -> f32 { fn joytag_threshold(app_data_dir: &Path) -> f32 {
match std::fs::read_to_string(app_data_dir.join(TAGGER_THRESHOLD_FILE)) { tagger_threshold_for_model(app_data_dir, TaggerModel::JoyTag)
Ok(value) => value
.trim()
.parse::<f32>()
.unwrap_or(JOYTAG_DEFAULT_THRESHOLD)
.clamp(0.01, 1.0),
Err(_) => JOYTAG_DEFAULT_THRESHOLD,
}
} }
fn sigmoid(x: f32) -> f32 { fn sigmoid(x: f32) -> f32 {
+4
View File
@@ -23,6 +23,8 @@ export default function App() {
const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress); const loadBackgroundJobProgress = useGalleryStore((state) => state.loadBackgroundJobProgress);
const loadImages = useGalleryStore((state) => state.loadImages); const loadImages = useGalleryStore((state) => state.loadImages);
const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus); const loadCaptionModelStatus = useGalleryStore((state) => state.loadCaptionModelStatus);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const loadTaggerModel = useGalleryStore((state) => state.loadTaggerModel);
const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache); const loadDuplicateScanCache = useGalleryStore((state) => state.loadDuplicateScanCache);
const loadAlbums = useGalleryStore((state) => state.loadAlbums); const loadAlbums = useGalleryStore((state) => state.loadAlbums);
const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds); const loadMutedFolderIds = useGalleryStore((state) => state.loadMutedFolderIds);
@@ -53,6 +55,8 @@ export default function App() {
loadFolders().then(async () => { loadFolders().then(async () => {
void loadBackgroundJobProgress(); void loadBackgroundJobProgress();
void loadCaptionModelStatus(); void loadCaptionModelStatus();
void loadTaggerModel();
void loadTaggerModelStatus();
void loadDuplicateScanCache(); void loadDuplicateScanCache();
await loadAlbums(); await loadAlbums();
await loadImages(true); await loadImages(true);
+15 -2
View File
@@ -156,6 +156,7 @@ export function Lightbox() {
const addUserTag = useGalleryStore((state) => state.addUserTag); const addUserTag = useGalleryStore((state) => state.addUserTag);
const removeTag = useGalleryStore((state) => state.removeTag); const removeTag = useGalleryStore((state) => state.removeTag);
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage); const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
const albums = useGalleryStore((state) => state.albums); const albums = useGalleryStore((state) => state.albums);
const addToAlbum = useGalleryStore((state) => state.addToAlbum); const addToAlbum = useGalleryStore((state) => state.addToAlbum);
@@ -254,6 +255,13 @@ export function Lightbox() {
const canStartSlideshow = slideshowImages.length > 0; const canStartSlideshow = slideshowImages.length > 0;
const canFindSimilar = selectedImage?.embedding_status === "ready"; const canFindSimilar = selectedImage?.embedding_status === "ready";
const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image"; const canSearchRegion = canFindSimilar && selectedImage?.media_kind === "image";
const taggerReady = taggerModelStatus?.ready ?? false;
const taggerStatusKnown = taggerModelStatus !== null;
const taggerButtonTooltip = !taggerStatusKnown
? "Checking AI tagger model..."
: taggerReady
? "Queue AI tagging for this image"
: "AI tagger model not installed";
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
if (currentIndex > 0) openImage(images[currentIndex - 1]); if (currentIndex > 0) openImage(images[currentIndex - 1]);
@@ -421,6 +429,11 @@ export function Lightbox() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [selectedImage?.id, selectedImage?.media_kind, getImageExif]); }, [selectedImage?.id, selectedImage?.media_kind, getImageExif]);
useEffect(() => {
if (selectedImage?.media_kind !== "image" || taggerStatusKnown) return;
void loadTaggerModelStatus();
}, [loadTaggerModelStatus, selectedImage?.media_kind, taggerStatusKnown]);
// Reset the queued state once the worker finishes so the button is usable again // Reset the queued state once the worker finishes so the button is usable again
useEffect(() => { useEffect(() => {
if (selectedImage?.ai_tagged_at) setTaggingQueued(false); if (selectedImage?.ai_tagged_at) setTaggingQueued(false);
@@ -1156,10 +1169,10 @@ export function Lightbox() {
</span> </span>
) : null} ) : null}
{selectedImage.media_kind === "image" ? ( {selectedImage.media_kind === "image" ? (
<Tooltip label={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"} followCursor> <Tooltip label={taggerButtonTooltip} followCursor>
<button <button
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40" className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] text-gray-400 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued} disabled={!taggerReady || taggingQueued}
onClick={() => { onClick={() => {
setTaggingQueued(true); setTaggingQueued(true);
void queueTaggingForImage(selectedImage.id).catch(() => undefined); void queueTaggingForImage(selectedImage.id).catch(() => undefined);
+113 -96
View File
@@ -4,11 +4,15 @@ import { FfmpegStatusRow } from "./onboarding/StepWelcome";
import { ThemedDropdown } from "./ThemedDropdown"; import { ThemedDropdown } from "./ThemedDropdown";
import { getChangelogForVersion } from "../changelog"; import { getChangelogForVersion } from "../changelog";
import { Tooltip } from "./Tooltip"; import { Tooltip } from "./Tooltip";
import { TAGGER_MODELS } from "../taggerModels";
type SettingsSection = "workspace" | "general"; type SettingsSection = "general" | "media" | "updates" | "storage" | "workspace";
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
{ id: "general", label: "General", detail: "App data and diagnostics" }, { id: "general", label: "General", detail: "Theme and notifications" },
{ id: "media", label: "Media", detail: "Playback and slideshow" },
{ id: "updates", label: "Updates & Setup", detail: "Versions, setup, and tour" },
{ id: "storage", label: "Storage", detail: "App data and maintenance" },
{ id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" },
]; ];
@@ -101,22 +105,6 @@ function ScopeButton({ scope, current, onSelect, children }: {
); );
} }
// Display metadata for each selectable tagging model.
const TAGGER_MODELS: Record<TaggerModel, { name: string; tab: string; description: string }> = {
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 }: { function TaggerModelButton({ model, current, onSelect, children }: {
model: TaggerModel; model: TaggerModel;
current: TaggerModel; current: TaggerModel;
@@ -282,7 +270,7 @@ export function SettingsModal() {
}, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); }, [settingsOpen, loadTaggerModelStatus, loadTaggerModel, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]);
useEffect(() => { useEffect(() => {
if (!settingsOpen || activeSection !== "general") return; if (!settingsOpen || activeSection !== "storage") return;
setVacuumResult(null); setVacuumResult(null);
setThumbnailCleanupResult(null); setThumbnailCleanupResult(null);
void getDatabaseInfo().then(setDbInfo).catch(() => {}); void getDatabaseInfo().then(setDbInfo).catch(() => {});
@@ -304,6 +292,7 @@ export function SettingsModal() {
if (!settingsOpen) return null; if (!settingsOpen) return null;
const activeSectionMeta = SECTIONS.find((section) => section.id === activeSection) ?? SECTIONS[0];
const taggerReady = taggerModelStatus?.ready ?? false; const taggerReady = taggerModelStatus?.ready ?? false;
const queueScopeLabel = const queueScopeLabel =
taggingQueueScope === "all" taggingQueueScope === "all"
@@ -322,7 +311,7 @@ export function SettingsModal() {
: taggerModelProgress : taggerModelProgress
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}` ? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
: taggerModelPreparing : taggerModelPreparing
? "Preparing WD Tagger..." ? "Preparing AI tagger..."
: taggerReady : taggerReady
? "Installed" ? "Installed"
: "Install model"; : "Install model";
@@ -445,21 +434,23 @@ export function SettingsModal() {
</div> </div>
</aside> </aside>
<Tooltip label="Close settings" anchorToCursor className="absolute right-4 top-4 z-10"> <div className="absolute right-4 top-4 z-10">
<button <Tooltip label="Close settings" anchorToCursor>
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white" <button
onClick={() => setSettingsOpen(false)} className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
> onClick={() => setSettingsOpen(false)}
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> >
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</button> </svg>
</Tooltip> </button>
</Tooltip>
</div>
<main className="min-w-0 flex-1 overflow-y-auto"> <main className="min-w-0 flex-1 overflow-y-auto">
<div className="px-10 py-8"> <div className="px-10 py-8">
<h3 className="text-lg font-semibold text-white">{activeSection === "workspace" ? "AI Workspace" : "General"}</h3> <h3 className="text-lg font-semibold text-white">{activeSectionMeta.label}</h3>
<p className="mt-1 text-xs text-gray-600">{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}</p> <p className="mt-1 text-xs text-gray-600">{activeSectionMeta.detail}</p>
{activeSection === "workspace" ? ( {activeSection === "workspace" ? (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
@@ -474,6 +465,9 @@ export function SettingsModal() {
current={taggerModel} current={taggerModel}
onSelect={(nextModel) => { onSelect={(nextModel) => {
if (nextModel === taggerModel) return; if (nextModel === taggerModel) return;
setTaggerThresholdDraft(null);
setTaggerThresholdError(null);
if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current);
setTaggerModelSwitching(true); setTaggerModelSwitching(true);
setTaggerModelSwitchError(null); setTaggerModelSwitchError(null);
void setTaggerModel(nextModel) void setTaggerModel(nextModel)
@@ -506,34 +500,45 @@ export function SettingsModal() {
} }
description={TAGGER_MODELS[taggerModel].description} description={TAGGER_MODELS[taggerModel].description}
> >
<div className="flex items-center gap-2"> <div className="flex flex-col items-end gap-1.5">
{taggerReady ? ( <div className="flex items-center gap-2">
<> {taggerReady ? (
<>
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void probeTaggerRuntime()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing}
>
Delete model files
</button>
</>
) : (
<button <button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => void probeTaggerRuntime()} onClick={() => void prepareTaggerModel()}
disabled={taggerRuntimeChecking}
>
{taggerRuntimeChecking ? "Checking runtime..." : "Check runtime"}
</button>
<button
className="rounded-md border border-red-400/20 bg-red-500/10 px-3 py-1.5 text-xs text-red-200 transition-colors hover:bg-red-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-red-400/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
onClick={() => void deleteTaggerModel()}
disabled={taggerModelPreparing} disabled={taggerModelPreparing}
> >
Delete model files {taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null}
<span className="relative">{taggerDownloadLabel}</span>
</button> </button>
</> )}
) : ( </div>
<button {taggerRuntimeProbe ? (
className="relative overflow-hidden rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white" <div className="text-right">
onClick={() => void prepareTaggerModel()} <p className="text-xs text-gray-400">
disabled={taggerModelPreparing} Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
> {" "}<span className="ml-2 text-gray-600">· acceleration: {taggerRuntimeProbe.acceleration}</span>
{taggerModelProgress ? <span className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200" style={{ width: `${taggerDownloadPercent}%` }} /> : null} </p>
<span className="relative">{taggerDownloadLabel}</span> <p className="mt-1 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
</button> </div>
)} ) : null}
</div> </div>
</SettingsItem> </SettingsItem>
@@ -575,12 +580,12 @@ export function SettingsModal() {
className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none" className="w-20 rounded-md border border-white/10 bg-white/5 px-2 py-1.5 text-xs text-white focus:border-white/20 focus:outline-none"
value={thresholdDisplay} value={thresholdDisplay}
onChange={(event) => setTaggerThresholdDraft(event.target.value)} onChange={(event) => setTaggerThresholdDraft(event.target.value)}
onBlur={() => { onBlur={(event) => {
const value = parseFloat(thresholdDisplay); const value = parseFloat(event.currentTarget.value);
if (!isNaN(value) && value >= 0.05 && value <= 0.99) { if (!isNaN(value) && value >= 0.05 && value <= 0.99) {
setTaggerThresholdError(null); setTaggerThresholdError(null);
setTaggerThresholdSaving(true); setTaggerThresholdSaving(true);
void setTaggerThreshold(value) void setTaggerThreshold(value, taggerModel)
.catch((error: unknown) => setTaggerThresholdError(String(error))) .catch((error: unknown) => setTaggerThresholdError(String(error)))
.finally(() => { .finally(() => {
setTaggerThresholdDraft(null); setTaggerThresholdDraft(null);
@@ -597,7 +602,7 @@ export function SettingsModal() {
{taggerThresholdError ? ( {taggerThresholdError ? (
<p className="text-[11px] text-amber-300">{taggerThresholdError}</p> <p className="text-[11px] text-amber-300">{taggerThresholdError}</p>
) : ( ) : (
<p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${taggerModel === "joytag" ? "0.4" : "0.35"}`}</p> <p className="text-[11px] text-gray-600">{taggerThresholdSaving ? "Saving..." : `Default: ${TAGGER_MODELS[taggerModel].defaultThreshold}`}</p>
)} )}
</div> </div>
</SettingsItem> </SettingsItem>
@@ -646,15 +651,6 @@ export function SettingsModal() {
</p> </p>
{taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null} {taggerModelProgress?.current_file ? <p className="mt-2 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p> : null}
{taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null} {taggerModelError ? <p className="mt-2 text-xs text-amber-300">{taggerModelError}</p> : null}
{taggerRuntimeProbe ? (
<div className="mt-3">
<p className="text-xs text-gray-400">
Runtime check <span className="ml-1.5 align-middle"><StatusPill tone="ready">Ready</StatusPill></span>
<span className="ml-2 text-gray-600">acceleration: {taggerRuntimeProbe.acceleration}</span>
</p>
<p className="mt-1.5 break-all font-mono text-xs text-gray-600">{taggerRuntimeProbe.session.file}</p>
</div>
) : null}
</div> </div>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
@@ -776,6 +772,8 @@ export function SettingsModal() {
</div> </div>
) : ( ) : (
<div className="mt-8 space-y-9"> <div className="mt-8 space-y-9">
{activeSection === "general" ? (
<>
<SettingsGroup title="Appearance"> <SettingsGroup title="Appearance">
<SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background."> <SettingsItem label="Theme" description="Choose the app palette. Subtle Light uses a warm, low-glare background.">
<ThemedDropdown <ThemedDropdown
@@ -791,6 +789,39 @@ export function SettingsModal() {
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Notifications">
<SettingsItem
label="Pause all notifications"
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
>
<button
role="switch"
aria-checked={notificationsPaused}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setNotificationsPaused(!notificationsPaused)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
<SettingsItem
label="Keep background pauses after restart"
description="When enabled, folders you pause from the sidebar or background bar stay paused the next time Phokus opens."
>
<button
role="switch"
aria-checked={workerPausesPersist}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${workerPausesPersist ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setWorkerPausesPersist(!workerPausesPersist)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${workerPausesPersist ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup>
</>
) : null}
{activeSection === "media" ? (
<>
<SettingsGroup title="Video playback"> <SettingsGroup title="Video playback">
<SettingsItem <SettingsItem
label="Autoplay in lightbox" label="Autoplay in lightbox"
@@ -869,6 +900,11 @@ export function SettingsModal() {
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</>
) : null}
{activeSection === "updates" ? (
<>
<SettingsGroup title="Updates"> <SettingsGroup title="Updates">
<SettingsItem <SettingsItem
label={ label={
@@ -963,7 +999,12 @@ export function SettingsModal() {
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Storage & notifications"> </>
) : null}
{activeSection === "storage" ? (
<>
<SettingsGroup title="App data">
<SettingsItem <SettingsItem
label="App data folder" label="App data folder"
description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models." description="Open the folder in Explorer to inspect or back up the database, thumbnails, and models."
@@ -979,32 +1020,6 @@ export function SettingsModal() {
{openingDataFolder ? "Opening..." : "Open data folder"} {openingDataFolder ? "Opening..." : "Open data folder"}
</button> </button>
</SettingsItem> </SettingsItem>
<SettingsItem
label="Pause all notifications"
description="Notifications are batched per folder — a single alert fires once activity settles. Mute individual folders from their right-click menu."
>
<button
role="switch"
aria-checked={notificationsPaused}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${notificationsPaused ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setNotificationsPaused(!notificationsPaused)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${notificationsPaused ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
<SettingsItem
label="Keep background pauses after restart"
description="When enabled, folders you pause from the sidebar or background bar stay paused the next time Phokus opens."
>
<button
role="switch"
aria-checked={workerPausesPersist}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none ${workerPausesPersist ? "bg-sky-500" : "bg-white/15"}`}
onClick={() => setWorkerPausesPersist(!workerPausesPersist)}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${workerPausesPersist ? "translate-x-4" : "translate-x-0"}`} />
</button>
</SettingsItem>
</SettingsGroup> </SettingsGroup>
<SettingsGroup title="Maintenance"> <SettingsGroup title="Maintenance">
@@ -1161,6 +1176,8 @@ export function SettingsModal() {
</button> </button>
</SettingsItem> </SettingsItem>
</SettingsGroup> </SettingsGroup>
</>
) : null}
</div> </div>
)} )}
</div> </div>
+1
View File
@@ -147,6 +147,7 @@ export function TitleBar() {
<Tooltip label="Settings (right-click to switch theme)" anchorToCursor> <Tooltip label="Settings (right-click to switch theme)" anchorToCursor>
<button <button
ref={settingsBtnRef} ref={settingsBtnRef}
aria-label="Settings"
onClick={() => setSettingsOpen(true)} onClick={() => setSettingsOpen(true)}
onContextMenu={handleSettingsContextMenu} onContextMenu={handleSettingsContextMenu}
className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300" className="group flex h-full w-10 items-center justify-center text-gray-600 transition-colors duration-100 hover:bg-white/6 hover:text-gray-300"
+2 -2
View File
@@ -326,7 +326,7 @@ export function Toolbar() {
className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`} className={`w-40 bg-transparent py-1.5 pr-9 text-sm text-white placeholder:text-gray-600 focus:outline-none transition-colors lg:w-52 xl:w-64 ${searchCommand !== null ? "pl-16" : "pl-8"}`}
/> />
{searchCommand !== null ? ( {searchCommand !== null ? (
<div className="absolute left-8 top-1/2 -translate-y-1/2"> <div className="absolute left-8 top-1/2 flex -translate-y-1/2">
<Tooltip label="Remove search command" anchorToCursor> <Tooltip label="Remove search command" anchorToCursor>
<button <button
type="button" type="button"
@@ -339,7 +339,7 @@ export function Toolbar() {
</div> </div>
) : null} ) : null}
{searchQuery.trim().length > 0 || searchCommand !== null ? ( {searchQuery.trim().length > 0 || searchCommand !== null ? (
<div className="absolute right-2 top-1/2 -translate-y-1/2"> <div className="absolute right-2 top-1/2 flex -translate-y-1/2">
<Tooltip label="Clear search" anchorToCursor> <Tooltip label="Clear search" anchorToCursor>
<button <button
aria-label="Clear search" aria-label="Clear search"
+52 -3
View File
@@ -1,5 +1,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { TaggerModelProgress, useGalleryStore } from "../../store"; import { TaggerModel, TaggerModelProgress, useGalleryStore } from "../../store";
import { TAGGER_MODELS } from "../../taggerModels";
import { FakeProgressBar, formatBytes } from "./fakes"; import { FakeProgressBar, formatBytes } from "./fakes";
const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"]; const FAKE_TAGS = ["landscape", "sunset", "outdoors", "no_humans", "ocean", "cloudy_sky"];
@@ -15,19 +16,47 @@ function taggerDownloadFraction(progress: TaggerModelProgress | null): number |
return null; return null;
} }
function TaggerModelChoice({ model, current, disabled, onSelect }: {
model: TaggerModel;
current: TaggerModel;
disabled: boolean;
onSelect: (model: TaggerModel) => void;
}) {
const active = model === current;
return (
<button
type="button"
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
active
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200 light-theme:border-emerald-600/50 light-theme:bg-emerald-100 light-theme:text-emerald-700"
: "border-transparent text-gray-500 hover:bg-white/[0.06] hover:text-gray-200 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
}`}
disabled={disabled}
onClick={() => onSelect(model)}
>
{TAGGER_MODELS[model].tab}
</button>
);
}
export function StepAiFeatures() { export function StepAiFeatures() {
const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus); const taggerModelStatus = useGalleryStore((s) => s.taggerModelStatus);
const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing); const taggerModelPreparing = useGalleryStore((s) => s.taggerModelPreparing);
const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress); const taggerModelProgress = useGalleryStore((s) => s.taggerModelProgress);
const taggerModelError = useGalleryStore((s) => s.taggerModelError); const taggerModelError = useGalleryStore((s) => s.taggerModelError);
const taggerModel = useGalleryStore((s) => s.taggerModel);
const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel); const prepareTaggerModel = useGalleryStore((s) => s.prepareTaggerModel);
const loadTaggerModel = useGalleryStore((s) => s.loadTaggerModel);
const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus); const loadTaggerModelStatus = useGalleryStore((s) => s.loadTaggerModelStatus);
const setTaggerModel = useGalleryStore((s) => s.setTaggerModel);
useEffect(() => { useEffect(() => {
void loadTaggerModel();
void loadTaggerModelStatus(); void loadTaggerModelStatus();
}, [loadTaggerModelStatus]); }, [loadTaggerModel, loadTaggerModelStatus]);
const taggerReady = taggerModelStatus?.ready ?? false; const taggerReady = taggerModelStatus?.ready ?? false;
const taggerStatusLabel = taggerReady ? "Installed" : taggerModelPreparing ? "Preparing" : "Not installed";
return ( return (
<div> <div>
@@ -43,9 +72,29 @@ export function StepAiFeatures() {
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-white">Automatic tags for every image</p> <p className="text-sm text-white">Automatic tags for every image</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500"> <p className="mt-1 text-xs leading-relaxed text-gray-500">
The WD tagger model (~1.3 GB download) labels images so you can search with{" "} The AI tagger model labels images so you can search with{" "}
<code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> tags look like: <code className="rounded bg-gray-900 px-1 py-0.5 text-[11px] text-gray-200 light-theme:bg-gray-800 light-theme:text-gray-100">/t</code> tags look like:
</p> </p>
<div className="mt-3 inline-flex max-w-full flex-wrap items-center gap-1 rounded-lg border border-white/[0.07] p-0.5 light-theme:border-gray-300/80">
{(["wd", "joytag"] as const).map((model) => (
<TaggerModelChoice
key={model}
model={model}
current={taggerModel}
disabled={taggerModelPreparing}
onSelect={(nextModel) => {
if (nextModel === taggerModel) return;
void setTaggerModel(nextModel);
}}
/>
))}
</div>
<p className="mt-2 text-[11px] text-gray-600">
Current: {TAGGER_MODELS[taggerModel].name} · {taggerStatusLabel}
</p>
<p className="mt-1 text-xs leading-relaxed text-gray-500">
{TAGGER_MODELS[taggerModel].description}
</p>
<span className="mt-2 flex flex-wrap gap-1.5"> <span className="mt-2 flex flex-wrap gap-1.5">
{FAKE_TAGS.map((tag) => ( {FAKE_TAGS.map((tag) => (
<span key={tag} className="rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400 light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600"> <span key={tag} className="rounded-md border border-white/10 bg-gray-900/50 px-2 py-0.5 text-[11px] text-gray-400 light-theme:border-gray-300/70 light-theme:bg-gray-900 light-theme:text-gray-600">
+75 -5
View File
@@ -1,5 +1,15 @@
import { emit } from "@tauri-apps/api/event"; import { emit } from "@tauri-apps/api/event";
import type { Album, ExploreTagEntry, Folder, ImageRecord, ImageTag, SortOrder } from "../store"; import type {
Album,
ExploreTagEntry,
Folder,
ImageRecord,
ImageTag,
SortOrder,
TaggerModel,
TaggerModelStatus,
TaggerRuntimeProbe,
} from "../store";
import { compareImages, createMockDb, mockExif } from "./mockFixtures"; import { compareImages, createMockDb, mockExif } from "./mockFixtures";
import { getMockScenario } from "./mockScenarios"; import { getMockScenario } from "./mockScenarios";
@@ -7,6 +17,12 @@ const db = createMockDb(getMockScenario());
let nextFolderId = 100; let nextFolderId = 100;
let nextAlbumId = 100; let nextAlbumId = 100;
let nextTagId = 10_000; let nextTagId = 10_000;
let mockTaggerModel: TaggerModel = db.scenario === "joytag-unready" ? "joytag" : "wd";
let mockTaggerReady = db.scenario !== "unready" && db.scenario !== "joytag-unready";
const mockTaggerThresholds: Record<TaggerModel, number> = {
wd: 0.35,
joytag: 0.4,
};
type AnyPayload = Record<string, any> | undefined; type AnyPayload = Record<string, any> | undefined;
type Rgb = [number, number, number]; type Rgb = [number, number, number];
@@ -30,6 +46,10 @@ function isRgb(value: unknown): value is Rgb {
return Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === "number"); return Array.isArray(value) && value.length === 3 && value.every((entry) => typeof entry === "number");
} }
function isTaggerModel(value: unknown): value is TaggerModel {
return value === "wd" || value === "joytag";
}
function colorDistanceSq(left: Rgb, right: Rgb): number { function colorDistanceSq(left: Rgb, right: Rgb): number {
return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2; return (left[0] - right[0]) ** 2 + (left[1] - right[1]) ** 2 + (left[2] - right[2]) ** 2;
} }
@@ -55,6 +75,42 @@ function page<T>(items: T[], offset = 0, limit = 200) {
}; };
} }
function mockTaggerStatus(): TaggerModelStatus {
const modelInfo =
mockTaggerModel === "joytag"
? {
model_id: "fancyfeast/joytag",
model_name: "JoyTag",
local_dir: "mock://models/joytag",
label_file: "top_tags.txt",
}
: {
model_id: "SmilingWolf/wd-swinv2-tagger-v3",
model_name: "wd-swinv2-tagger-v3",
local_dir: "mock://models/wd-swinv2-tagger-v3",
label_file: "selected_tags.csv",
};
return {
model_id: modelInfo.model_id,
model_name: modelInfo.model_name,
local_dir: modelInfo.local_dir,
ready: mockTaggerReady,
missing_files: mockTaggerReady ? [] : ["model.onnx", modelInfo.label_file],
};
}
function mockTaggerRuntimeProbe(): TaggerRuntimeProbe {
if (!mockTaggerReady) {
throw new Error(`${mockTaggerStatus().model_name} is missing required model files`);
}
return {
ready: true,
acceleration: "auto",
session: { file: "model.onnx", inputs: ["input"], outputs: ["tags"] },
};
}
function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] { function filterImages(input: ImageRecord[], payload: unknown): ImageRecord[] {
const p = params(payload); const p = params(payload);
const query = String(p.search ?? p.query ?? "").trim().toLowerCase(); const query = String(p.search ?? p.query ?? "").trim().toLowerCase();
@@ -463,21 +519,35 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
return reset; return reset;
} }
case "get_tagger_model_status": 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: [] }; return mockTaggerStatus();
case "get_tagger_model": case "get_tagger_model":
return mockTaggerModel;
case "set_tagger_model": case "set_tagger_model":
return p.model ?? "wd"; mockTaggerModel = p.model ?? "wd";
mockTaggerReady = db.scenario !== "unready" && db.scenario !== "joytag-unready";
return mockTaggerModel;
case "prepare_tagger_model":
mockTaggerReady = true;
return mockTaggerStatus();
case "delete_tagger_model":
mockTaggerReady = false;
return mockTaggerStatus();
case "get_tagger_acceleration": case "get_tagger_acceleration":
case "set_tagger_acceleration": case "set_tagger_acceleration":
return p.acceleration ?? "auto"; return p.acceleration ?? "auto";
case "get_tagger_threshold": case "get_tagger_threshold":
return mockTaggerThresholds[mockTaggerModel];
case "set_tagger_threshold": case "set_tagger_threshold":
return p.threshold ?? 0.35; {
const model = isTaggerModel(p.model) ? p.model : mockTaggerModel;
mockTaggerThresholds[model] = p.threshold ?? mockTaggerThresholds[model];
return mockTaggerThresholds[model];
}
case "get_tagger_batch_size": case "get_tagger_batch_size":
case "set_tagger_batch_size": case "set_tagger_batch_size":
return p.batch_size ?? 8; return p.batch_size ?? 8;
case "probe_tagger_runtime": case "probe_tagger_runtime":
return { ready: true, acceleration: "auto", session: { file: "model.onnx", inputs: ["input"], outputs: ["tags"] } }; return mockTaggerRuntimeProbe();
case "get_tagging_queue_scope": case "get_tagging_queue_scope":
return "all"; return "all";
case "get_tagging_queue_folder_ids": case "get_tagging_queue_folder_ids":
+13 -1
View File
@@ -1,4 +1,14 @@
export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge" | "extreme"; export type MockScenario =
| "rich"
| "empty"
| "busy"
| "duplicates"
| "album"
| "errors"
| "huge"
| "extreme"
| "unready"
| "joytag-unready";
const SCENARIOS = new Set<MockScenario>([ const SCENARIOS = new Set<MockScenario>([
"rich", "rich",
@@ -9,6 +19,8 @@ const SCENARIOS = new Set<MockScenario>([
"errors", "errors",
"huge", "huge",
"extreme", "extreme",
"unready",
"joytag-unready",
]); ]);
export function getMockScenario(): MockScenario { export function getMockScenario(): MockScenario {
+71 -18
View File
@@ -589,7 +589,7 @@ interface GalleryState {
loadTaggerModel: () => Promise<void>; loadTaggerModel: () => Promise<void>;
setTaggerModel: (model: TaggerModel) => Promise<void>; setTaggerModel: (model: TaggerModel) => Promise<void>;
loadTaggerThreshold: () => Promise<void>; loadTaggerThreshold: () => Promise<void>;
setTaggerThreshold: (threshold: number) => Promise<void>; setTaggerThreshold: (threshold: number, model?: TaggerModel) => Promise<void>;
loadTaggerBatchSize: () => Promise<void>; loadTaggerBatchSize: () => Promise<void>;
setTaggerBatchSize: (batchSize: number) => Promise<void>; setTaggerBatchSize: (batchSize: number) => Promise<void>;
probeTaggerRuntime: () => Promise<void>; probeTaggerRuntime: () => Promise<void>;
@@ -651,6 +651,34 @@ let galleryRequestToken = 0;
let visualClusterRequestToken = 0; let visualClusterRequestToken = 0;
let exploreTagRequestToken = 0; let exploreTagRequestToken = 0;
let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null; let exploreTagRefreshTimer: ReturnType<typeof setTimeout> | null = null;
const EXPLORE_TAG_REFRESH_IDLE_MS = 900;
const EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS = 4500;
function scopeHasTaggingPending(
progressByFolder: Record<number, FolderJobProgress>,
folderId: number | null,
): boolean {
if (folderId === null) {
return Object.values(progressByFolder).some((progress) => progress.tagging_pending > 0);
}
return (progressByFolder[folderId]?.tagging_pending ?? 0) > 0;
}
function taggingProgressAffectsScope(progressFolderId: number, scopeFolderId: number | null): boolean {
return scopeFolderId === null || scopeFolderId === progressFolderId;
}
function imagesAffectScope(images: ImageRecord[], scopeFolderId: number | null): boolean {
return scopeFolderId === null || images.some((image) => image.folder_id === scopeFolderId);
}
function scheduleExploreTagRefresh(load: () => void, delayMs: number) {
if (exploreTagRefreshTimer) clearTimeout(exploreTagRefreshTimer);
exploreTagRefreshTimer = setTimeout(() => {
exploreTagRefreshTimer = null;
load();
}, delayMs);
}
function initialAiCaptionsEnabled(): boolean { function initialAiCaptionsEnabled(): boolean {
if (typeof window === "undefined") return false; if (typeof window === "undefined") return false;
@@ -1498,6 +1526,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
loadExploreTags: async (options) => { loadExploreTags: async (options) => {
const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get(); const { selectedFolderId, exploreTagsFolderId, exploreTagLoading } = get();
const force = options?.force ?? false; const force = options?.force ?? false;
if (!force && exploreTagLoading) {
return;
}
if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) { if (!force && !exploreTagLoading && exploreTagsFolderId !== undefined && exploreTagsFolderId === selectedFolderId) {
return; return;
} }
@@ -2326,11 +2357,14 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const taggerModel = await invoke<TaggerModel>("set_tagger_model", { const taggerModel = await invoke<TaggerModel>("set_tagger_model", {
params: { model }, params: { model },
}); });
// Switching models changes which files are required on disk, so refresh the // Switching models changes both readiness and the active threshold setting,
// status too — the download/ready UI must reflect the newly-selected model. // so refresh them together for the selected model.
try { try {
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status"); const [taggerModelStatus, taggerThreshold] = await Promise.all([
set({ taggerModel, taggerModelStatus, taggerModelError: null, taggerRuntimeProbe: null }); invoke<TaggerModelStatus>("get_tagger_model_status"),
invoke<number>("get_tagger_threshold"),
]);
set({ taggerModel, taggerModelStatus, taggerThreshold, taggerModelError: null, taggerRuntimeProbe: null });
} catch (error) { } catch (error) {
set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) }); set({ taggerModel, taggerRuntimeProbe: null, taggerModelError: String(error) });
} }
@@ -2345,11 +2379,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
} }
}, },
setTaggerThreshold: async (threshold) => { setTaggerThreshold: async (threshold, model) => {
const taggerThreshold = await invoke<number>("set_tagger_threshold", { const taggerThreshold = await invoke<number>("set_tagger_threshold", {
params: { threshold }, params: { threshold, model },
}); });
set({ taggerThreshold }); if (!model || get().taggerModel === model) {
set({ taggerThreshold });
}
}, },
loadTaggerBatchSize: async () => { loadTaggerBatchSize: async () => {
@@ -2979,9 +3015,18 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
notificationTimers.set(taggingKey, setTimeout(() => { notificationTimers.set(taggingKey, setTimeout(() => {
notificationTimers.delete(taggingKey); notificationTimers.delete(taggingKey);
void notifyTaskComplete("AI tagging complete", body); void notifyTaskComplete("AI tagging complete", body);
// New tags landed — invalidate Explore tag cache.
set({ exploreTagsFolderId: undefined });
}, NOTIFICATION_DEBOUNCE_MS)); }, NOTIFICATION_DEBOUNCE_MS));
const state = get();
if (taggingProgressAffectsScope(progress.folder_id, state.selectedFolderId)) {
// New tags landed — refresh the Explore tag cloud after the worker
// settles instead of waiting for the user to revisit Explore.
set({ exploreTagsFolderId: undefined });
if (state.activeView === "explore" && state.exploreMode === "tags") {
scheduleExploreTagRefresh(() => {
void get().loadExploreTags({ force: true });
}, EXPLORE_TAG_REFRESH_IDLE_MS);
}
}
} else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) { } else if (previous.tagging_pending === 0 && progress.tagging_pending > 0) {
clearTimeout(notificationTimers.get(taggingKey)); clearTimeout(notificationTimers.get(taggingKey));
notificationTimers.delete(taggingKey); notificationTimers.delete(taggingKey);
@@ -3010,6 +3055,9 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
taggerModelProgress: event.payload.done ? null : event.payload, taggerModelProgress: event.payload.done ? null : event.payload,
taggerModelPreparing: !event.payload.done, taggerModelPreparing: !event.payload.done,
}); });
if (event.payload.done) {
void get().loadTaggerModelStatus();
}
}); });
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => { const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
@@ -3059,15 +3107,16 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => { const unlistenThumbnails = await listen<ThumbnailBatch>("media-updated", (event) => {
const batch = event.payload; const batch = event.payload;
const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null); const taggingUpdated = batch.images.some((image) => image.ai_tagged_at !== null || image.ai_tagger_error !== null);
if (taggingUpdated) { const state = get();
if (taggingUpdated && imagesAffectScope(batch.images, state.selectedFolderId)) {
set({ exploreTagsFolderId: undefined }); set({ exploreTagsFolderId: undefined });
if (get().activeView === "explore") { if (state.activeView === "explore" && state.exploreMode === "tags") {
if (!exploreTagRefreshTimer) { const delay = scopeHasTaggingPending(state.mediaJobProgress, state.selectedFolderId)
exploreTagRefreshTimer = setTimeout(() => { ? EXPLORE_TAG_REFRESH_WHILE_TAGGING_MS
exploreTagRefreshTimer = null; : EXPLORE_TAG_REFRESH_IDLE_MS;
void get().loadExploreTags({ force: true }); scheduleExploreTagRefresh(() => {
}, 700); void get().loadExploreTags({ force: true });
} }, delay);
} }
} }
@@ -3171,6 +3220,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
); );
return () => { return () => {
if (exploreTagRefreshTimer) {
clearTimeout(exploreTagRefreshTimer);
exploreTagRefreshTimer = null;
}
unlistenProgress(); unlistenProgress();
unlistenMediaJobs(); unlistenMediaJobs();
unlistenCaptionModelProgress(); unlistenCaptionModelProgress();
+18
View File
@@ -0,0 +1,18 @@
import type { TaggerModel } from "./store";
export const TAGGER_MODELS: Record<TaggerModel, { name: string; tab: string; description: string; defaultThreshold: number }> = {
wd: {
name: "WD SwinV2 Tagger v3",
tab: "WD (anime)",
defaultThreshold: 0.35,
description:
"Anime-focused vision model by SmilingWolf. Generates booru-style tags with configurable confidence thresholds.",
},
joytag: {
name: "JoyTag",
tab: "JoyTag (general)",
defaultThreshold: 0.4,
description:
"Booru-schema tagger that also handles photographic content and is strong on NSFW concepts. The explicitness rating is derived from its tags.",
},
};