feat: add WD tagger, remove caption UI, add per-image tag queuing in lightbox
- Add WD SwinV2 tagger v3 backend (tagger.rs) with DirectML acceleration - Expose tagger commands (model status, download, queue, tags CRUD) - Add queueTaggingForImage store action and AI tags button in lightbox sidebar - Remove all caption UI surfaces (Lightbox, BackgroundTasks, SettingsModal) - Disable caption worker in lib.rs; keep Rust backend intact for future use - Make lightbox sidebar scrollable with sticky header/footer - Collapse long tag lists (show 8, expandable) in lightbox sidebar
This commit is contained in:
+23
-1
@@ -5,6 +5,7 @@ mod embedder;
|
|||||||
mod indexer;
|
mod indexer;
|
||||||
mod media;
|
mod media;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
mod tagger;
|
||||||
mod thumbnail;
|
mod thumbnail;
|
||||||
mod vector;
|
mod vector;
|
||||||
|
|
||||||
@@ -67,7 +68,9 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
|
indexer::start_metadata_worker(app.handle().clone(), pool.clone(), media_tools.clone());
|
||||||
indexer::start_embedding_worker(app.handle().clone(), pool.clone());
|
indexer::start_embedding_worker(app.handle().clone(), pool.clone());
|
||||||
indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
// Caption worker disabled — UI removed; keeping backend code intact for future use.
|
||||||
|
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
|
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
|
|
||||||
app.manage(pool);
|
app.manage(pool);
|
||||||
app.manage(media_tools);
|
app.manage(media_tools);
|
||||||
@@ -87,18 +90,37 @@ pub fn run() {
|
|||||||
commands::retry_failed_embeddings,
|
commands::retry_failed_embeddings,
|
||||||
commands::semantic_search_images,
|
commands::semantic_search_images,
|
||||||
commands::get_caption_model_status,
|
commands::get_caption_model_status,
|
||||||
|
commands::get_caption_acceleration,
|
||||||
|
commands::set_caption_acceleration,
|
||||||
|
commands::get_caption_detail,
|
||||||
|
commands::set_caption_detail,
|
||||||
commands::prepare_caption_model,
|
commands::prepare_caption_model,
|
||||||
commands::delete_caption_model,
|
commands::delete_caption_model,
|
||||||
commands::probe_caption_runtime,
|
commands::probe_caption_runtime,
|
||||||
commands::probe_caption_image,
|
commands::probe_caption_image,
|
||||||
commands::generate_caption_for_image,
|
commands::generate_caption_for_image,
|
||||||
commands::queue_caption_jobs,
|
commands::queue_caption_jobs,
|
||||||
|
commands::clear_caption_jobs,
|
||||||
|
commands::reset_generated_captions,
|
||||||
commands::set_generated_caption,
|
commands::set_generated_caption,
|
||||||
commands::suggest_image_tags,
|
commands::suggest_image_tags,
|
||||||
commands::set_worker_paused,
|
commands::set_worker_paused,
|
||||||
commands::get_worker_states,
|
commands::get_worker_states,
|
||||||
commands::get_tag_cloud,
|
commands::get_tag_cloud,
|
||||||
commands::get_failed_embedding_images,
|
commands::get_failed_embedding_images,
|
||||||
|
commands::get_tagger_model_status,
|
||||||
|
commands::get_tagger_acceleration,
|
||||||
|
commands::set_tagger_acceleration,
|
||||||
|
commands::probe_tagger_runtime,
|
||||||
|
commands::get_tagger_threshold,
|
||||||
|
commands::set_tagger_threshold,
|
||||||
|
commands::prepare_tagger_model,
|
||||||
|
commands::delete_tagger_model,
|
||||||
|
commands::queue_tagging_jobs,
|
||||||
|
commands::clear_tagging_jobs,
|
||||||
|
commands::get_image_tags,
|
||||||
|
commands::add_user_tag,
|
||||||
|
commands::remove_tag,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore } from "../store";
|
||||||
|
|
||||||
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "caption";
|
type WorkerKey = "thumbnail" | "metadata" | "embedding" | "tagging";
|
||||||
|
|
||||||
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
const WORKER_FOR_STAGE: Record<string, WorkerKey> = {
|
||||||
Thumbnails: "thumbnail",
|
Thumbnails: "thumbnail",
|
||||||
Metadata: "metadata",
|
Metadata: "metadata",
|
||||||
Embeddings: "embedding",
|
Embeddings: "embedding",
|
||||||
Captions: "caption",
|
Tags: "tagging",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface TaskStage {
|
interface TaskStage {
|
||||||
@@ -23,7 +23,7 @@ interface Task {
|
|||||||
name: string;
|
name: string;
|
||||||
stages: TaskStage[];
|
stages: TaskStage[];
|
||||||
hasFailedEmbeddings: boolean;
|
hasFailedEmbeddings: boolean;
|
||||||
hasFailedCaptions: boolean;
|
hasFailedTagging: boolean;
|
||||||
pendingMediaWork: number;
|
pendingMediaWork: number;
|
||||||
embeddingProcessed: number;
|
embeddingProcessed: number;
|
||||||
embeddingTotal: number;
|
embeddingTotal: number;
|
||||||
@@ -42,14 +42,14 @@ interface FolderWorkerStates {
|
|||||||
thumbnail_paused: boolean;
|
thumbnail_paused: boolean;
|
||||||
metadata_paused: boolean;
|
metadata_paused: boolean;
|
||||||
embedding_paused: boolean;
|
embedding_paused: boolean;
|
||||||
caption_paused: boolean;
|
tagging_paused: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
|
const DEFAULT_PAUSED_STATE: Record<WorkerKey, boolean> = {
|
||||||
thumbnail: false,
|
thumbnail: false,
|
||||||
metadata: false,
|
metadata: false,
|
||||||
embedding: false,
|
embedding: false,
|
||||||
caption: false,
|
tagging: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BackgroundTasks() {
|
export function BackgroundTasks() {
|
||||||
@@ -57,6 +57,7 @@ export function BackgroundTasks() {
|
|||||||
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
const indexingProgress = useGalleryStore((state) => state.indexingProgress);
|
||||||
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress);
|
||||||
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings);
|
||||||
|
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
const [dismissed, setDismissed] = useState<Record<number, string>>({});
|
||||||
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
|
const [paused, setPaused] = useState<Record<number, Record<WorkerKey, boolean>>>({});
|
||||||
@@ -78,7 +79,7 @@ export function BackgroundTasks() {
|
|||||||
thumbnail: state.thumbnail_paused,
|
thumbnail: state.thumbnail_paused,
|
||||||
metadata: state.metadata_paused,
|
metadata: state.metadata_paused,
|
||||||
embedding: state.embedding_paused,
|
embedding: state.embedding_paused,
|
||||||
caption: state.caption_paused,
|
tagging: state.tagging_paused,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -126,6 +127,7 @@ export function BackgroundTasks() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const dismissTask = (id: number, snapshot: string) => {
|
const dismissTask = (id: number, snapshot: string) => {
|
||||||
|
void clearTaggingJobs(id);
|
||||||
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
setDismissed((prev) => ({ ...prev, [id]: snapshot }));
|
||||||
setExpanded(false);
|
setExpanded(false);
|
||||||
};
|
};
|
||||||
@@ -141,16 +143,19 @@ export function BackgroundTasks() {
|
|||||||
const embeddingPending = jobs?.embedding_pending ?? 0;
|
const embeddingPending = jobs?.embedding_pending ?? 0;
|
||||||
const embeddingReady = jobs?.embedding_ready ?? 0;
|
const embeddingReady = jobs?.embedding_ready ?? 0;
|
||||||
const embeddingFailed = jobs?.embedding_failed ?? 0;
|
const embeddingFailed = jobs?.embedding_failed ?? 0;
|
||||||
const captionPending = jobs?.caption_pending ?? 0;
|
const taggingPending = jobs?.tagging_pending ?? 0;
|
||||||
const captionFailed = jobs?.caption_failed ?? 0;
|
const taggingReady = jobs?.tagging_ready ?? 0;
|
||||||
|
const taggingFailed = jobs?.tagging_failed ?? 0;
|
||||||
|
|
||||||
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + captionPending;
|
const pendingMediaWork = thumbnailPending + metadataPending + embeddingPending + taggingPending;
|
||||||
const embeddingProcessed = embeddingReady + embeddingFailed;
|
const embeddingProcessed = embeddingReady + embeddingFailed;
|
||||||
const embeddingTotal = embeddingProcessed + embeddingPending;
|
const embeddingTotal = embeddingProcessed + embeddingPending;
|
||||||
|
const taggingProcessed = taggingReady + taggingFailed;
|
||||||
|
const taggingTotal = taggingProcessed + taggingPending;
|
||||||
const hasFailedEmbeddings = embeddingFailed > 0;
|
const hasFailedEmbeddings = embeddingFailed > 0;
|
||||||
const hasFailedCaptions = captionFailed > 0;
|
const hasFailedTagging = taggingFailed > 0;
|
||||||
|
|
||||||
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedCaptions) return null;
|
if (!index && pendingMediaWork === 0 && !hasFailedEmbeddings && !hasFailedTagging) return null;
|
||||||
|
|
||||||
const stages: TaskStage[] = [];
|
const stages: TaskStage[] = [];
|
||||||
|
|
||||||
@@ -192,11 +197,12 @@ export function BackgroundTasks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (captionPending > 0) {
|
if (taggingPending > 0) {
|
||||||
|
const pct = taggingTotal > 0 ? (taggingProcessed / taggingTotal) * 100 : 0;
|
||||||
stages.push({
|
stages.push({
|
||||||
label: "Captions",
|
label: "Tags",
|
||||||
detail: captionPending.toLocaleString(),
|
detail: `${taggingProcessed.toLocaleString()} / ${taggingTotal.toLocaleString()}`,
|
||||||
progress: null,
|
progress: pct,
|
||||||
failed: false,
|
failed: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -210,23 +216,23 @@ export function BackgroundTasks() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFailedCaptions && pendingMediaWork === 0) {
|
if (hasFailedTagging && pendingMediaWork === 0) {
|
||||||
stages.push({
|
stages.push({
|
||||||
label: "Failed",
|
label: "Failed",
|
||||||
detail: `${captionFailed.toLocaleString()} captions`,
|
detail: `${taggingFailed.toLocaleString()} tags`,
|
||||||
progress: null,
|
progress: null,
|
||||||
failed: true,
|
failed: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${captionFailed}`;
|
const snapshot = `${pendingMediaWork}:${embeddingFailed}:${taggingFailed}`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: folder.id,
|
id: folder.id,
|
||||||
name: folder.name,
|
name: folder.name,
|
||||||
stages,
|
stages,
|
||||||
hasFailedEmbeddings,
|
hasFailedEmbeddings,
|
||||||
hasFailedCaptions,
|
hasFailedTagging,
|
||||||
pendingMediaWork,
|
pendingMediaWork,
|
||||||
embeddingProcessed,
|
embeddingProcessed,
|
||||||
embeddingTotal,
|
embeddingTotal,
|
||||||
@@ -242,13 +248,14 @@ export function BackgroundTasks() {
|
|||||||
|
|
||||||
const primary = tasks[0];
|
const primary = tasks[0];
|
||||||
const extraCount = tasks.length - 1;
|
const extraCount = tasks.length - 1;
|
||||||
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedCaptions) && t.pendingMediaWork === 0);
|
const hasFailed = tasks.some((t) => (t.hasFailedEmbeddings || t.hasFailedTagging) && t.pendingMediaWork === 0);
|
||||||
|
|
||||||
// Best progress bar value: use embedding progress if available (most informative),
|
// Best progress bar value: use embedding progress if available (most informative),
|
||||||
// otherwise fall back to scanning progress, otherwise indeterminate.
|
// otherwise tagging progress, otherwise fall back to scanning progress, otherwise indeterminate.
|
||||||
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
const embeddingStage = primary.stages.find((s) => s.label === "Embeddings");
|
||||||
|
const taggingStage = primary.stages.find((s) => s.label === "Tags");
|
||||||
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
const scanningStage = primary.stages.find((s) => s.label === "Scanning");
|
||||||
const barProgress = embeddingStage?.progress ?? scanningStage?.progress ?? null;
|
const barProgress = embeddingStage?.progress ?? taggingStage?.progress ?? scanningStage?.progress ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 border-b border-white/[0.06]">
|
<div className="shrink-0 border-b border-white/[0.06]">
|
||||||
@@ -368,9 +375,10 @@ export function BackgroundTasks() {
|
|||||||
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
|
<div className="border-t border-white/[0.06] bg-white/[0.02] px-5 py-3 space-y-3">
|
||||||
{tasks.map((task) => {
|
{tasks.map((task) => {
|
||||||
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
const taskEmbeddingStage = task.stages.find((s) => s.label === "Embeddings");
|
||||||
|
const taskTaggingStage = task.stages.find((s) => s.label === "Tags");
|
||||||
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
const taskScanningStage = task.stages.find((s) => s.label === "Scanning");
|
||||||
const taskBarProgress = taskEmbeddingStage?.progress ?? taskScanningStage?.progress ?? null;
|
const taskBarProgress = taskEmbeddingStage?.progress ?? taskTaggingStage?.progress ?? taskScanningStage?.progress ?? null;
|
||||||
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedCaptions) && task.pendingMediaWork === 0;
|
const taskHasFailed = (task.hasFailedEmbeddings || task.hasFailedTagging) && task.pendingMediaWork === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={task.id}>
|
<div key={task.id}>
|
||||||
|
|||||||
+127
-131
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useCallback, useRef, useState } from "react";
|
import { useEffect, useCallback, useRef, useState } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
import { convertFileSrc } from "@tauri-apps/api/core";
|
||||||
import { useGalleryStore } from "../store";
|
import { useGalleryStore, ImageTag, AiRating } from "../store";
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -45,29 +45,37 @@ function embeddingLabel(status: string, model: string | null): string {
|
|||||||
return "Queued";
|
return "Queued";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ratingPill(rating: AiRating): { label: string; className: string } {
|
||||||
|
switch (rating) {
|
||||||
|
case "general":
|
||||||
|
return { label: "General", className: "border-emerald-400/25 bg-emerald-500/10 text-emerald-300" };
|
||||||
|
case "sensitive":
|
||||||
|
return { label: "Sensitive", className: "border-sky-400/25 bg-sky-500/10 text-sky-300" };
|
||||||
|
case "questionable":
|
||||||
|
return { label: "Questionable", className: "border-amber-400/25 bg-amber-500/10 text-amber-300" };
|
||||||
|
case "explicit":
|
||||||
|
return { label: "Explicit", className: "border-red-400/25 bg-red-500/10 text-red-300" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Lightbox() {
|
export function Lightbox() {
|
||||||
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
const selectedImage = useGalleryStore((state) => state.selectedImage);
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
|
||||||
const closeImage = useGalleryStore((state) => state.closeImage);
|
const closeImage = useGalleryStore((state) => state.closeImage);
|
||||||
const images = useGalleryStore((state) => state.images);
|
const images = useGalleryStore((state) => state.images);
|
||||||
const openImage = useGalleryStore((state) => state.openImage);
|
const openImage = useGalleryStore((state) => state.openImage);
|
||||||
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
const loadSimilarImages = useGalleryStore((state) => state.loadSimilarImages);
|
||||||
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
const updateImageDetails = useGalleryStore((state) => state.updateImageDetails);
|
||||||
const suggestImageTags = useGalleryStore((state) => state.suggestImageTags);
|
const getImageTags = useGalleryStore((state) => state.getImageTags);
|
||||||
const captionModelStatus = useGalleryStore((state) => state.captionModelStatus);
|
const addUserTag = useGalleryStore((state) => state.addUserTag);
|
||||||
const captionModelPreparing = useGalleryStore((state) => state.captionModelPreparing);
|
const removeTag = useGalleryStore((state) => state.removeTag);
|
||||||
const captionModelError = useGalleryStore((state) => state.captionModelError);
|
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||||||
const captionModelProgress = useGalleryStore((state) => state.captionModelProgress);
|
const queueTaggingForImage = useGalleryStore((state) => state.queueTaggingForImage);
|
||||||
const aiCaptionsEnabled = useGalleryStore((state) => state.aiCaptionsEnabled);
|
|
||||||
const setAiCaptionsEnabled = useGalleryStore((state) => state.setAiCaptionsEnabled);
|
|
||||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
|
||||||
const prepareCaptionModel = useGalleryStore((state) => state.prepareCaptionModel);
|
|
||||||
const queueCaptionForImage = useGalleryStore((state) => state.queueCaptionForImage);
|
|
||||||
const queueCaptionJobs = useGalleryStore((state) => state.queueCaptionJobs);
|
|
||||||
const [zoom, setZoom] = useState(1);
|
const [zoom, setZoom] = useState(1);
|
||||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
const [imageTags, setImageTags] = useState<ImageTag[]>([]);
|
||||||
const [captionQueueing, setCaptionQueueing] = useState(false);
|
const [tagInput, setTagInput] = useState("");
|
||||||
const [captionQueueStatus, setCaptionQueueStatus] = useState<string | null>(null);
|
const [tagAdding, setTagAdding] = useState(false);
|
||||||
|
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||||
|
const [taggingQueued, setTaggingQueued] = useState(false);
|
||||||
const imageViewportRef = useRef<HTMLDivElement>(null);
|
const imageViewportRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
const currentIndex = selectedImage ? images.findIndex((image) => image.id === selectedImage.id) : -1;
|
||||||
@@ -83,16 +91,18 @@ export function Lightbox() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setZoom(1);
|
setZoom(1);
|
||||||
setSuggestedTags([]);
|
setImageTags([]);
|
||||||
setCaptionQueueStatus(null);
|
setTagInput("");
|
||||||
|
setTagsExpanded(false);
|
||||||
|
setTaggingQueued(false);
|
||||||
}, [selectedImage?.id]);
|
}, [selectedImage?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedImage?.generated_caption) return;
|
if (!selectedImage) return;
|
||||||
void suggestImageTags(selectedImage.id)
|
void getImageTags(selectedImage.id)
|
||||||
.then(setSuggestedTags)
|
.then(setImageTags)
|
||||||
.catch(() => setSuggestedTags([]));
|
.catch(() => setImageTags([]));
|
||||||
}, [selectedImage?.id, selectedImage?.generated_caption, suggestImageTags]);
|
}, [selectedImage?.id, getImageTags]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const viewport = imageViewportRef.current;
|
const viewport = imageViewportRef.current;
|
||||||
@@ -207,8 +217,8 @@ export function Lightbox() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95 p-5">
|
<div className="flex w-72 shrink-0 flex-col border-l border-white/5 bg-gray-900/95">
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="flex shrink-0 items-center justify-between px-5 pt-5 pb-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
<p className="truncate text-sm font-medium text-white">{selectedImage.filename}</p>
|
||||||
<p className="text-xs text-gray-500">Details</p>
|
<p className="text-xs text-gray-500">Details</p>
|
||||||
@@ -245,7 +255,7 @@ export function Lightbox() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4 text-sm">
|
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-4 space-y-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
|
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">Rating</p>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -341,116 +351,102 @@ export function Lightbox() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1 text-xs uppercase tracking-wider text-gray-500">AI Caption</p>
|
<div className="mb-2 flex items-center justify-between">
|
||||||
{selectedImage.generated_caption ? (
|
<p className="text-xs uppercase tracking-wider text-gray-500">Tags</p>
|
||||||
<>
|
<div className="flex items-center gap-1.5">
|
||||||
<p className="text-white">{selectedImage.generated_caption}</p>
|
{selectedImage.ai_rating ? (
|
||||||
{suggestedTags.length > 0 ? (
|
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${ratingPill(selectedImage.ai_rating).className}`}>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
{ratingPill(selectedImage.ai_rating).label}
|
||||||
{suggestedTags.map((tag) => (
|
</span>
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-gray-300"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{selectedImage.caption_model ? (
|
|
||||||
<p className="mt-1 text-xs text-gray-600">{selectedImage.caption_model}</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
{selectedImage.caption_error ?? "Not generated"}
|
|
||||||
</p>
|
|
||||||
{captionModelStatus?.ready ? (
|
|
||||||
<>
|
|
||||||
<p className={`mt-2 text-xs ${aiCaptionsEnabled ? "text-emerald-400/70" : "text-gray-600"}`}>
|
|
||||||
{aiCaptionsEnabled ? "Florence-2 enabled locally" : "Florence-2 downloaded but disabled"}
|
|
||||||
</p>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
|
|
||||||
onClick={() => setAiCaptionsEnabled(!aiCaptionsEnabled)}
|
|
||||||
>
|
|
||||||
{aiCaptionsEnabled ? "Disable captions" : "Enable captions"}
|
|
||||||
</button>
|
|
||||||
{selectedImage.media_kind === "image" && aiCaptionsEnabled ? (
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 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-50"
|
|
||||||
onClick={() => {
|
|
||||||
setCaptionQueueing(true);
|
|
||||||
setCaptionQueueStatus(null);
|
|
||||||
void queueCaptionForImage(selectedImage.id)
|
|
||||||
.then(() => setCaptionQueueStatus("Queued for background captions."))
|
|
||||||
.catch((error) => setCaptionQueueStatus(String(error)))
|
|
||||||
.finally(() => setCaptionQueueing(false));
|
|
||||||
}}
|
|
||||||
disabled={captionQueueing}
|
|
||||||
>
|
|
||||||
{captionQueueing ? "Queueing..." : "Queue caption"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
{aiCaptionsEnabled ? (
|
|
||||||
<button
|
|
||||||
className="rounded-md border border-white/10 bg-white/5 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-50"
|
|
||||||
onClick={() => {
|
|
||||||
setCaptionQueueing(true);
|
|
||||||
setCaptionQueueStatus(null);
|
|
||||||
void queueCaptionJobs(selectedFolderId)
|
|
||||||
.then((queued) =>
|
|
||||||
setCaptionQueueStatus(
|
|
||||||
queued === 0
|
|
||||||
? "No missing captions found."
|
|
||||||
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"} for background captions.`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch((error) => setCaptionQueueStatus(String(error)))
|
|
||||||
.finally(() => setCaptionQueueing(false));
|
|
||||||
}}
|
|
||||||
disabled={captionQueueing}
|
|
||||||
>
|
|
||||||
{selectedFolderId === null ? "Queue all" : "Queue folder"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{captionQueueStatus ? (
|
|
||||||
<p className="mt-2 break-all text-xs text-gray-500">{captionQueueStatus}</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="mt-2 rounded-md border border-white/10 bg-white/5 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-50"
|
|
||||||
onClick={() => void prepareCaptionModel()}
|
|
||||||
disabled={captionModelPreparing}
|
|
||||||
>
|
|
||||||
{captionModelProgress
|
|
||||||
? `Downloading ${captionModelProgress.completed_files}/${captionModelProgress.total_files}`
|
|
||||||
: captionModelPreparing
|
|
||||||
? "Preparing Florence-2..."
|
|
||||||
: "Enable local captions"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{captionModelProgress?.current_file ? (
|
|
||||||
<p className="mt-2 break-all text-xs text-gray-600">{captionModelProgress.current_file}</p>
|
|
||||||
) : null}
|
|
||||||
{captionModelError ? (
|
|
||||||
<p className="mt-2 text-xs text-amber-300">{captionModelError}</p>
|
|
||||||
) : null}
|
|
||||||
{!captionModelStatus?.ready && !captionModelError ? (
|
|
||||||
<p className="mt-2 text-xs text-gray-600">Downloads Florence-2 on demand for offline captions.</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="mt-2 block text-xs text-gray-500 transition-colors hover:text-gray-300"
|
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"
|
||||||
onClick={() => setSettingsOpen(true)}
|
disabled={!(taggerModelStatus?.ready ?? false) || taggingQueued}
|
||||||
|
title={!(taggerModelStatus?.ready ?? false) ? "WD Tagger model not downloaded" : "Queue AI tagging for this image"}
|
||||||
|
onClick={() => {
|
||||||
|
setTaggingQueued(true);
|
||||||
|
void queueTaggingForImage(selectedImage.id).catch(() => undefined);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Open settings
|
{taggingQueued ? "Queued" : "AI tags"}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{imageTags.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{(tagsExpanded ? imageTags : imageTags.slice(0, 8)).map((t) => (
|
||||||
|
<span
|
||||||
|
key={t.id}
|
||||||
|
className={`group flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs ${
|
||||||
|
t.source === "ai"
|
||||||
|
? "border-sky-400/20 bg-sky-500/8 text-sky-300"
|
||||||
|
: "border-white/10 bg-white/5 text-gray-300"
|
||||||
|
}`}
|
||||||
|
title={t.source === "ai" && t.confidence !== null ? `AI confidence: ${(t.confidence * 100).toFixed(0)}%` : undefined}
|
||||||
|
>
|
||||||
|
{t.tag}
|
||||||
|
<button
|
||||||
|
className="text-gray-600 opacity-0 transition-opacity hover:text-white group-hover:opacity-100"
|
||||||
|
onClick={() => {
|
||||||
|
void removeTag(t.id).then(() =>
|
||||||
|
setImageTags((prev) => prev.filter((x) => x.id !== t.id)),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
title="Remove tag"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{imageTags.length > 8 && (
|
||||||
|
<button
|
||||||
|
className="mt-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
onClick={() => setTagsExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{tagsExpanded ? "Show less" : `+${imageTags.length - 8} more`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600">No tags yet</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<form
|
||||||
|
className="mt-2 flex gap-1.5"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const raw = tagInput.trim();
|
||||||
|
if (!raw || tagAdding) return;
|
||||||
|
setTagAdding(true);
|
||||||
|
void addUserTag(selectedImage.id, raw)
|
||||||
|
.then((newTag) => {
|
||||||
|
setImageTags((prev) => [...prev, newTag]);
|
||||||
|
setTagInput("");
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => setTagAdding(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-white placeholder-gray-600 focus:border-white/20 focus:outline-none"
|
||||||
|
placeholder="Add tag…"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
disabled={tagAdding}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md border border-white/10 bg-white/5 px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={tagAdding || !tagInput.trim()}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -459,7 +455,7 @@ export function Lightbox() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto pt-4 text-center text-xs text-gray-600">
|
<div className="shrink-0 mt-auto px-5 pb-4 pt-2 text-center text-xs text-gray-600">
|
||||||
{currentIndex + 1} / {images.length}
|
{currentIndex + 1} / {images.length}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+290
-182
@@ -1,48 +1,15 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useGalleryStore } from "../store";
|
import { TaggerAcceleration, useGalleryStore } from "../store";
|
||||||
|
|
||||||
type SettingsSection = "ai" | "library" | "display" | "storage";
|
type SettingsSection = "tagging" | "library" | "display" | "storage";
|
||||||
|
|
||||||
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [
|
||||||
{ id: "ai", label: "Local AI", detail: "Captions and suggestions" },
|
{ id: "tagging", label: "AI Tagging", detail: "WD tagger model" },
|
||||||
{ id: "library", label: "Library", detail: "Indexing and scanning" },
|
{ id: "library", label: "Library", detail: "Indexing and scanning" },
|
||||||
{ id: "display", label: "Display", detail: "Gallery preferences" },
|
{ id: "display", label: "Display", detail: "Gallery preferences" },
|
||||||
{ id: "storage", label: "Storage", detail: "Cache and model files" },
|
{ id: "storage", label: "Storage", detail: "Cache and model files" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function ToggleSwitch({
|
|
||||||
checked,
|
|
||||||
disabled,
|
|
||||||
onChange,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
checked: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
aria-checked={checked}
|
|
||||||
aria-label={label}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onChange(!checked)}
|
|
||||||
className={`relative h-7 w-12 shrink-0 rounded-full border transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-emerald-400/30 disabled:cursor-not-allowed disabled:opacity-45 ${
|
|
||||||
checked
|
|
||||||
? "border-emerald-400/50 bg-emerald-500/35"
|
|
||||||
: "border-white/12 bg-white/[0.06]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute left-0.5 top-0.5 h-6 w-6 rounded-full bg-white shadow-lg shadow-black/40 transition-transform duration-150 ${
|
|
||||||
checked ? "translate-x-5" : "translate-x-0"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
|
function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) {
|
||||||
const className =
|
const className =
|
||||||
@@ -59,6 +26,34 @@ function StatusPill({ children, tone }: { children: React.ReactNode; tone: "read
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TaggerAccelerationButton({
|
||||||
|
acceleration,
|
||||||
|
current,
|
||||||
|
onSelect,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
acceleration: TaggerAcceleration;
|
||||||
|
current: TaggerAcceleration;
|
||||||
|
onSelect: (acceleration: TaggerAcceleration) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const active = acceleration === current;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-emerald-400/35 bg-emerald-500/15 text-emerald-200"
|
||||||
|
: "border-white/10 bg-white/[0.045] text-gray-500 hover:bg-white/[0.075] hover:text-gray-200"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelect(acceleration)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function SettingsRow({
|
function SettingsRow({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -98,50 +93,52 @@ function SectionShell({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsModal() {
|
export function SettingsModal() {
|
||||||
const [activeSection, setActiveSection] = useState<SettingsSection>("ai");
|
const [activeSection, setActiveSection] = useState<SettingsSection>("tagging");
|
||||||
const [captionQueueStatus, setCaptionQueueStatus] = useState<string | null>(null);
|
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
||||||
const [captionQueueing, setCaptionQueueing] = useState(false);
|
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
||||||
|
const [taggerClearing, setTaggerClearing] = useState(false);
|
||||||
|
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
|
||||||
|
const [taggerThresholdDraft, setTaggerThresholdDraft] = useState<string | null>(null);
|
||||||
|
const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false);
|
||||||
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
|
const settingsOpen = useGalleryStore((state) => state.settingsOpen);
|
||||||
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen);
|
||||||
const folders = useGalleryStore((state) => state.folders);
|
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
const captionModelStatus = useGalleryStore((state) => state.captionModelStatus);
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
const captionModelPreparing = useGalleryStore((state) => state.captionModelPreparing);
|
const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus);
|
||||||
const captionModelProgress = useGalleryStore((state) => state.captionModelProgress);
|
const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing);
|
||||||
const captionModelError = useGalleryStore((state) => state.captionModelError);
|
const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress);
|
||||||
const captionRuntimeProbe = useGalleryStore((state) => state.captionRuntimeProbe);
|
const taggerModelError = useGalleryStore((state) => state.taggerModelError);
|
||||||
const captionRuntimeChecking = useGalleryStore((state) => state.captionRuntimeChecking);
|
const taggerAcceleration = useGalleryStore((state) => state.taggerAcceleration);
|
||||||
const aiCaptionsEnabled = useGalleryStore((state) => state.aiCaptionsEnabled);
|
const taggerThreshold = useGalleryStore((state) => state.taggerThreshold);
|
||||||
const setAiCaptionsEnabled = useGalleryStore((state) => state.setAiCaptionsEnabled);
|
const taggerRuntimeProbe = useGalleryStore((state) => state.taggerRuntimeProbe);
|
||||||
const prepareCaptionModel = useGalleryStore((state) => state.prepareCaptionModel);
|
const taggerRuntimeChecking = useGalleryStore((state) => state.taggerRuntimeChecking);
|
||||||
const deleteCaptionModel = useGalleryStore((state) => state.deleteCaptionModel);
|
const loadTaggerModelStatus = useGalleryStore((state) => state.loadTaggerModelStatus);
|
||||||
const probeCaptionRuntime = useGalleryStore((state) => state.probeCaptionRuntime);
|
const prepareTaggerModel = useGalleryStore((state) => state.prepareTaggerModel);
|
||||||
const queueCaptionJobs = useGalleryStore((state) => state.queueCaptionJobs);
|
const deleteTaggerModel = useGalleryStore((state) => state.deleteTaggerModel);
|
||||||
|
const loadTaggerAcceleration = useGalleryStore((state) => state.loadTaggerAcceleration);
|
||||||
|
const setTaggerAcceleration = useGalleryStore((state) => state.setTaggerAcceleration);
|
||||||
|
const loadTaggerThreshold = useGalleryStore((state) => state.loadTaggerThreshold);
|
||||||
|
const setTaggerThreshold = useGalleryStore((state) => state.setTaggerThreshold);
|
||||||
|
const probeTaggerRuntime = useGalleryStore((state) => state.probeTaggerRuntime);
|
||||||
|
const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs);
|
||||||
|
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingsOpen) return;
|
if (!settingsOpen) return;
|
||||||
|
void loadTaggerModelStatus();
|
||||||
|
void loadTaggerAcceleration();
|
||||||
|
void loadTaggerThreshold();
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === "Escape") setSettingsOpen(false);
|
if (event.key === "Escape") setSettingsOpen(false);
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [settingsOpen, setSettingsOpen]);
|
}, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, setSettingsOpen]);
|
||||||
|
|
||||||
if (!settingsOpen) return null;
|
if (!settingsOpen) return null;
|
||||||
|
|
||||||
const modelReady = captionModelStatus?.ready ?? false;
|
|
||||||
const downloadLabel = captionModelProgress
|
|
||||||
? `Downloading ${captionModelProgress.completed_files}/${captionModelProgress.total_files}`
|
|
||||||
: captionModelPreparing
|
|
||||||
? "Preparing Florence-2..."
|
|
||||||
: modelReady
|
|
||||||
? "Downloaded"
|
|
||||||
: "Download Florence-2";
|
|
||||||
const downloadPercent = captionModelProgress
|
|
||||||
? Math.round((captionModelProgress.completed_files / Math.max(captionModelProgress.total_files, 1)) * 100)
|
|
||||||
: 0;
|
|
||||||
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
const selectedFolder = folders.find((folder) => folder.id === selectedFolderId);
|
||||||
const captionScopeLabel = selectedFolder ? selectedFolder.name : "all libraries";
|
const scopeLabel = selectedFolder ? selectedFolder.name : "all libraries";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -176,11 +173,7 @@ export function SettingsModal() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex min-w-0 flex-1 flex-col">
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
<div className="flex h-14 shrink-0 items-center justify-between border-b border-white/[0.07] px-6">
|
<div className="flex h-14 shrink-0 items-center justify-end border-b border-white/[0.07] px-6">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{modelReady ? <StatusPill tone="ready">Florence-2 ready</StatusPill> : <StatusPill tone="muted">Optional</StatusPill>}
|
|
||||||
{captionModelPreparing ? <StatusPill tone="busy">Working</StatusPill> : null}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="rounded-md p-1.5 text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
onClick={() => setSettingsOpen(false)}
|
onClick={() => setSettingsOpen(false)}
|
||||||
@@ -193,123 +186,238 @@ export function SettingsModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-7 py-6">
|
<div className="flex-1 overflow-y-auto px-7 py-6">
|
||||||
{activeSection === "ai" ? (
|
{activeSection === "tagging" ? (() => {
|
||||||
<SectionShell eyebrow="Local AI" title="Captions and suggested tags">
|
const taggerReady = taggerModelStatus?.ready ?? false;
|
||||||
<SettingsRow
|
const taggerDownloadLabel = taggerModelProgress
|
||||||
title="AI captions"
|
? `Downloading ${taggerModelProgress.completed_files}/${taggerModelProgress.total_files}`
|
||||||
description="Generate captions and suggested tags with the local Florence-2 model."
|
: taggerModelPreparing
|
||||||
>
|
? "Preparing WD Tagger..."
|
||||||
<ToggleSwitch
|
: taggerReady
|
||||||
checked={aiCaptionsEnabled && modelReady}
|
? "Downloaded"
|
||||||
disabled={!modelReady || captionModelPreparing}
|
: "Download WD Tagger";
|
||||||
onChange={setAiCaptionsEnabled}
|
const taggerDownloadPercent = taggerModelProgress
|
||||||
label="AI captions"
|
? Math.round((taggerModelProgress.completed_files / Math.max(taggerModelProgress.total_files, 1)) * 100)
|
||||||
/>
|
: 0;
|
||||||
</SettingsRow>
|
const thresholdDisplay = taggerThresholdDraft ?? String(taggerThreshold);
|
||||||
|
return (
|
||||||
<SettingsRow
|
<SectionShell eyebrow="AI Tagging" title="WD SwinV2 Tagger v3">
|
||||||
title="Florence-2 model"
|
<SettingsRow
|
||||||
description={modelReady ? "Stored locally and available offline." : "Download the model before enabling local captions."}
|
title="WD Tagger model"
|
||||||
>
|
description={taggerReady ? "Stored locally and available offline." : "Download the model to enable automatic AI tagging."}
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<button
|
|
||||||
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"
|
|
||||||
onClick={() => void prepareCaptionModel()}
|
|
||||||
disabled={captionModelPreparing || modelReady}
|
|
||||||
>
|
|
||||||
{captionModelProgress ? (
|
|
||||||
<span
|
|
||||||
className="absolute inset-y-0 left-0 bg-emerald-400/15 transition-[width] duration-200"
|
|
||||||
style={{ width: `${downloadPercent}%` }}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<span className="relative">{downloadLabel}</span>
|
|
||||||
</button>
|
|
||||||
{modelReady ? (
|
|
||||||
<>
|
|
||||||
<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"
|
|
||||||
onClick={() => void probeCaptionRuntime()}
|
|
||||||
disabled={captionRuntimeChecking}
|
|
||||||
>
|
|
||||||
{captionRuntimeChecking ? "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"
|
|
||||||
onClick={() => void deleteCaptionModel()}
|
|
||||||
disabled={captionModelPreparing || captionRuntimeChecking}
|
|
||||||
>
|
|
||||||
Delete model files
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
title="Caption queue"
|
|
||||||
description={`Generate missing captions in ${captionScopeLabel}. Captions update in the gallery as the local worker finishes each image.`}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
onClick={() => {
|
|
||||||
setCaptionQueueing(true);
|
|
||||||
setCaptionQueueStatus(null);
|
|
||||||
void queueCaptionJobs(selectedFolderId)
|
|
||||||
.then((queued) => {
|
|
||||||
setCaptionQueueStatus(
|
|
||||||
queued === 0
|
|
||||||
? "No missing captions found."
|
|
||||||
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"}.`,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch((error) => setCaptionQueueStatus(String(error)))
|
|
||||||
.finally(() => setCaptionQueueing(false));
|
|
||||||
}}
|
|
||||||
disabled={!modelReady || !aiCaptionsEnabled || captionQueueing}
|
|
||||||
>
|
>
|
||||||
{captionQueueing ? "Queueing..." : selectedFolder ? "Caption this library" : "Caption all libraries"}
|
<div className="flex flex-col items-end gap-2">
|
||||||
</button>
|
<button
|
||||||
</SettingsRow>
|
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"
|
||||||
|
onClick={() => void prepareTaggerModel()}
|
||||||
|
disabled={taggerModelPreparing || taggerReady}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
{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"
|
||||||
|
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"
|
||||||
|
onClick={() => void deleteTaggerModel()}
|
||||||
|
disabled={taggerModelPreparing}
|
||||||
|
>
|
||||||
|
Delete model files
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
<div className="py-4">
|
<SettingsRow
|
||||||
<p className="text-xs font-medium text-gray-400">Model location</p>
|
title="Tagger acceleration"
|
||||||
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
|
description="Use DirectML for GPU-accelerated tagging when available."
|
||||||
{modelReady ? captionModelStatus?.local_dir : "Not downloaded"}
|
>
|
||||||
</p>
|
<div className="flex flex-col items-end gap-2">
|
||||||
{captionModelProgress?.current_file ? (
|
<div className="flex rounded-lg border border-white/[0.07] bg-black/20 p-1">
|
||||||
<p className="mt-3 break-all text-xs text-gray-500">{captionModelProgress.current_file}</p>
|
<TaggerAccelerationButton
|
||||||
) : null}
|
acceleration="auto"
|
||||||
{captionModelError ? (
|
current={taggerAcceleration}
|
||||||
<p className="mt-3 text-xs text-amber-300">{captionModelError}</p>
|
onSelect={(acceleration) => {
|
||||||
) : null}
|
setTaggerAccelerationSaving(true);
|
||||||
{captionQueueStatus ? (
|
void setTaggerAcceleration(acceleration)
|
||||||
<p className="mt-3 text-xs text-gray-500">{captionQueueStatus}</p>
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
) : null}
|
.finally(() => setTaggerAccelerationSaving(false));
|
||||||
{captionRuntimeProbe ? (
|
}}
|
||||||
<div className="mt-4 border-t border-white/[0.07] pt-4">
|
>
|
||||||
<div className="flex items-center justify-between gap-3">
|
Auto
|
||||||
<p className="text-xs font-medium text-gray-400">Runtime check</p>
|
</TaggerAccelerationButton>
|
||||||
<StatusPill tone="ready">Ready</StatusPill>
|
<TaggerAccelerationButton
|
||||||
|
acceleration="directml"
|
||||||
|
current={taggerAcceleration}
|
||||||
|
onSelect={(acceleration) => {
|
||||||
|
setTaggerAccelerationSaving(true);
|
||||||
|
void setTaggerAcceleration(acceleration)
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => setTaggerAccelerationSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
DirectML
|
||||||
|
</TaggerAccelerationButton>
|
||||||
|
<TaggerAccelerationButton
|
||||||
|
acceleration="cpu"
|
||||||
|
current={taggerAcceleration}
|
||||||
|
onSelect={(acceleration) => {
|
||||||
|
setTaggerAccelerationSaving(true);
|
||||||
|
void setTaggerAcceleration(acceleration)
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => setTaggerAccelerationSaving(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
CPU
|
||||||
|
</TaggerAccelerationButton>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-xs text-gray-600">
|
<p className="text-[11px] text-gray-600">
|
||||||
Tokenizer vocabulary: {captionRuntimeProbe.tokenizer_vocab_size.toLocaleString()}
|
{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 space-y-2">
|
</div>
|
||||||
{captionRuntimeProbe.sessions.map((session) => (
|
</SettingsRow>
|
||||||
<div key={session.file} className="rounded-md border border-white/[0.07] bg-black/20 px-3 py-2">
|
|
||||||
<p className="break-all text-xs text-gray-400">{session.file}</p>
|
<SettingsRow
|
||||||
|
title="Confidence threshold"
|
||||||
|
description="Tags with confidence below this value are discarded. Lower = more tags, higher = fewer but more accurate."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.05"
|
||||||
|
max="0.99"
|
||||||
|
step="0.05"
|
||||||
|
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}
|
||||||
|
onChange={(e) => setTaggerThresholdDraft(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const val = parseFloat(thresholdDisplay);
|
||||||
|
if (!isNaN(val) && val >= 0.05 && val <= 0.99) {
|
||||||
|
setTaggerThresholdSaving(true);
|
||||||
|
void setTaggerThreshold(val)
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => {
|
||||||
|
setTaggerThresholdDraft(null);
|
||||||
|
setTaggerThresholdSaving(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTaggerThresholdDraft(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-600">
|
||||||
|
{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
title="Tagging queue"
|
||||||
|
description={`Generate missing AI tags in ${scopeLabel}. Tags update as the background worker finishes each image.`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<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"
|
||||||
|
onClick={() => {
|
||||||
|
setTaggerQueueing(true);
|
||||||
|
setTaggerQueueStatus(null);
|
||||||
|
void queueTaggingJobs(selectedFolderId)
|
||||||
|
.then((queued) => {
|
||||||
|
setTaggerQueueStatus(
|
||||||
|
queued === 0
|
||||||
|
? "No missing tags found."
|
||||||
|
: `Queued ${queued.toLocaleString()} image${queued === 1 ? "" : "s"}.`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => setTaggerQueueing(false));
|
||||||
|
}}
|
||||||
|
disabled={!taggerReady || taggerQueueing || taggerClearing}
|
||||||
|
>
|
||||||
|
{taggerQueueing
|
||||||
|
? "Queueing..."
|
||||||
|
: selectedFolder
|
||||||
|
? "Tag this library"
|
||||||
|
: "Tag all libraries"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45"
|
||||||
|
onClick={() => {
|
||||||
|
setTaggerClearing(true);
|
||||||
|
setTaggerQueueStatus(null);
|
||||||
|
void clearTaggingJobs(selectedFolderId)
|
||||||
|
.then((cleared) => {
|
||||||
|
setTaggerQueueStatus(
|
||||||
|
cleared === 0
|
||||||
|
? "No queued tagging jobs to clear."
|
||||||
|
: `Cleared ${cleared.toLocaleString()} queued job${cleared === 1 ? "" : "s"}.`,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((error) => setTaggerQueueStatus(String(error)))
|
||||||
|
.finally(() => setTaggerClearing(false));
|
||||||
|
}}
|
||||||
|
disabled={taggerQueueing || taggerClearing}
|
||||||
|
>
|
||||||
|
{taggerClearing
|
||||||
|
? "Clearing..."
|
||||||
|
: selectedFolder
|
||||||
|
? "Clear this queue"
|
||||||
|
: "Clear all queued tags"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-xs font-medium text-gray-400">Model location</p>
|
||||||
|
<p className="mt-2 break-all rounded-md border border-white/[0.07] bg-black/20 px-3 py-2 text-xs text-gray-600">
|
||||||
|
{taggerReady ? taggerModelStatus?.local_dir : "Not downloaded"}
|
||||||
|
</p>
|
||||||
|
{taggerModelProgress?.current_file ? (
|
||||||
|
<p className="mt-3 break-all text-xs text-gray-500">{taggerModelProgress.current_file}</p>
|
||||||
|
) : null}
|
||||||
|
{taggerModelError ? (
|
||||||
|
<p className="mt-3 text-xs text-amber-300">{taggerModelError}</p>
|
||||||
|
) : null}
|
||||||
|
{taggerQueueStatus ? (
|
||||||
|
<p className="mt-3 text-xs text-gray-500">{taggerQueueStatus}</p>
|
||||||
|
) : null}
|
||||||
|
{taggerRuntimeProbe ? (
|
||||||
|
<div className="mt-4 border-t border-white/[0.07] pt-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-xs font-medium text-gray-400">Runtime check</p>
|
||||||
|
<StatusPill tone="ready">Ready</StatusPill>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-gray-600">
|
||||||
|
Tagger acceleration: {taggerRuntimeProbe.acceleration}
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="rounded-md border border-white/[0.07] bg-black/20 px-3 py-2">
|
||||||
|
<p className="break-all text-xs text-gray-400">{taggerRuntimeProbe.session.file}</p>
|
||||||
<p className="mt-1 text-[11px] text-gray-600">
|
<p className="mt-1 text-[11px] text-gray-600">
|
||||||
{session.inputs.length} input{session.inputs.length === 1 ? "" : "s"} · {session.outputs.length} output{session.outputs.length === 1 ? "" : "s"}
|
{taggerRuntimeProbe.session.inputs.length} input{taggerRuntimeProbe.session.inputs.length === 1 ? "" : "s"} · {taggerRuntimeProbe.session.outputs.join(", ")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
</div>
|
</SectionShell>
|
||||||
</SectionShell>
|
);
|
||||||
) : null}
|
})() : null}
|
||||||
|
|
||||||
{activeSection === "library" ? (
|
{activeSection === "library" ? (
|
||||||
<SectionShell eyebrow="Library" title="Indexing and scanning">
|
<SectionShell eyebrow="Library" title="Indexing and scanning">
|
||||||
@@ -335,11 +443,11 @@ export function SettingsModal() {
|
|||||||
|
|
||||||
{activeSection === "storage" ? (
|
{activeSection === "storage" ? (
|
||||||
<SectionShell eyebrow="Storage" title="Local files">
|
<SectionShell eyebrow="Storage" title="Local files">
|
||||||
<SettingsRow title="Florence-2" description="Remove the local model without changing the rest of the library.">
|
<SettingsRow title="WD Tagger" description="Remove the local tagger model without changing the rest of the library.">
|
||||||
<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"
|
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"
|
||||||
onClick={() => void deleteCaptionModel()}
|
onClick={() => void deleteTaggerModel()}
|
||||||
disabled={captionModelPreparing || !modelReady}
|
disabled={taggerModelPreparing || !(taggerModelStatus?.ready ?? false)}
|
||||||
>
|
>
|
||||||
Delete model files
|
Delete model files
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+268
-4
@@ -15,6 +15,10 @@ export type MediaKind = "image" | "video";
|
|||||||
export type MediaFilter = "all" | MediaKind;
|
export type MediaFilter = "all" | MediaKind;
|
||||||
export type ZoomPreset = "compact" | "comfortable" | "detail";
|
export type ZoomPreset = "compact" | "comfortable" | "detail";
|
||||||
export type SearchMode = "filename" | "semantic";
|
export type SearchMode = "filename" | "semantic";
|
||||||
|
export type CaptionAcceleration = "auto" | "cpu" | "directml";
|
||||||
|
export type CaptionDetail = "short" | "detailed" | "paragraph";
|
||||||
|
export type TaggerAcceleration = "auto" | "cpu" | "directml";
|
||||||
|
export type AiRating = "general" | "sensitive" | "questionable" | "explicit";
|
||||||
|
|
||||||
export interface ImageRecord {
|
export interface ImageRecord {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -44,6 +48,35 @@ export interface ImageRecord {
|
|||||||
caption_model: string | null;
|
caption_model: string | null;
|
||||||
caption_updated_at: string | null;
|
caption_updated_at: string | null;
|
||||||
caption_error: string | null;
|
caption_error: string | null;
|
||||||
|
ai_rating: AiRating | null;
|
||||||
|
ai_tagger_model: string | null;
|
||||||
|
ai_tagged_at: string | null;
|
||||||
|
ai_tagger_error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageTag {
|
||||||
|
id: number;
|
||||||
|
image_id: number;
|
||||||
|
tag: string;
|
||||||
|
source: "user" | "ai";
|
||||||
|
ai_model: string | null;
|
||||||
|
confidence: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaggerModelStatus {
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
local_dir: string;
|
||||||
|
ready: boolean;
|
||||||
|
missing_files: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaggerModelProgress {
|
||||||
|
total_files: number;
|
||||||
|
completed_files: number;
|
||||||
|
current_file: string | null;
|
||||||
|
done: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IndexProgress {
|
export interface IndexProgress {
|
||||||
@@ -64,6 +97,9 @@ export interface FolderJobProgress {
|
|||||||
caption_pending: number;
|
caption_pending: number;
|
||||||
caption_ready: number;
|
caption_ready: number;
|
||||||
caption_failed: number;
|
caption_failed: number;
|
||||||
|
tagging_pending: number;
|
||||||
|
tagging_ready: number;
|
||||||
|
tagging_failed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaJobProgressEvent {
|
export interface MediaJobProgressEvent {
|
||||||
@@ -110,6 +146,8 @@ export interface CaptionRuntimeSessionProbe {
|
|||||||
|
|
||||||
export interface CaptionRuntimeProbe {
|
export interface CaptionRuntimeProbe {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
|
acceleration: CaptionAcceleration;
|
||||||
|
detail: CaptionDetail;
|
||||||
tokenizer_vocab_size: number;
|
tokenizer_vocab_size: number;
|
||||||
sessions: CaptionRuntimeSessionProbe[];
|
sessions: CaptionRuntimeSessionProbe[];
|
||||||
}
|
}
|
||||||
@@ -118,6 +156,19 @@ export interface CaptionVisionProbe {
|
|||||||
input_shape: number[];
|
input_shape: number[];
|
||||||
output_shape: number[];
|
output_shape: number[];
|
||||||
output_values: number;
|
output_values: number;
|
||||||
|
acceleration: CaptionAcceleration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaggerRuntimeSessionProbe {
|
||||||
|
file: string;
|
||||||
|
inputs: string[];
|
||||||
|
outputs: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaggerRuntimeProbe {
|
||||||
|
ready: boolean;
|
||||||
|
acceleration: TaggerAcceleration;
|
||||||
|
session: TaggerRuntimeSessionProbe;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SortOrder =
|
export type SortOrder =
|
||||||
@@ -163,9 +214,20 @@ interface GalleryState {
|
|||||||
captionModelProgress: CaptionModelProgress | null;
|
captionModelProgress: CaptionModelProgress | null;
|
||||||
captionRuntimeProbe: CaptionRuntimeProbe | null;
|
captionRuntimeProbe: CaptionRuntimeProbe | null;
|
||||||
captionRuntimeChecking: boolean;
|
captionRuntimeChecking: boolean;
|
||||||
|
captionAcceleration: CaptionAcceleration;
|
||||||
|
captionDetail: CaptionDetail;
|
||||||
aiCaptionsEnabled: boolean;
|
aiCaptionsEnabled: boolean;
|
||||||
settingsOpen: boolean;
|
settingsOpen: boolean;
|
||||||
|
|
||||||
|
taggerModelStatus: TaggerModelStatus | null;
|
||||||
|
taggerModelPreparing: boolean;
|
||||||
|
taggerModelError: string | null;
|
||||||
|
taggerModelProgress: TaggerModelProgress | null;
|
||||||
|
taggerAcceleration: TaggerAcceleration;
|
||||||
|
taggerThreshold: number;
|
||||||
|
taggerRuntimeProbe: TaggerRuntimeProbe | null;
|
||||||
|
taggerRuntimeChecking: boolean;
|
||||||
|
|
||||||
loadFolders: () => Promise<void>;
|
loadFolders: () => Promise<void>;
|
||||||
loadBackgroundJobProgress: () => Promise<void>;
|
loadBackgroundJobProgress: () => Promise<void>;
|
||||||
addFolder: (path: string) => Promise<void>;
|
addFolder: (path: string) => Promise<void>;
|
||||||
@@ -198,12 +260,33 @@ interface GalleryState {
|
|||||||
generateCaptionForImage: (imageId: number) => Promise<ImageRecord>;
|
generateCaptionForImage: (imageId: number) => Promise<ImageRecord>;
|
||||||
queueCaptionJobs: (folderId?: number | null) => Promise<number>;
|
queueCaptionJobs: (folderId?: number | null) => Promise<number>;
|
||||||
queueCaptionForImage: (imageId: number) => Promise<number>;
|
queueCaptionForImage: (imageId: number) => Promise<number>;
|
||||||
|
clearCaptionJobs: (folderId?: number | null) => Promise<number>;
|
||||||
|
resetGeneratedCaptions: (folderId?: number | null) => Promise<number>;
|
||||||
|
loadCaptionAcceleration: () => Promise<void>;
|
||||||
|
setCaptionAcceleration: (acceleration: CaptionAcceleration) => Promise<void>;
|
||||||
|
loadCaptionDetail: () => Promise<void>;
|
||||||
|
setCaptionDetail: (detail: CaptionDetail) => Promise<void>;
|
||||||
setAiCaptionsEnabled: (enabled: boolean) => void;
|
setAiCaptionsEnabled: (enabled: boolean) => void;
|
||||||
setSettingsOpen: (open: boolean) => void;
|
setSettingsOpen: (open: boolean) => void;
|
||||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||||
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
||||||
setCacheDir: (dir: string) => void;
|
setCacheDir: (dir: string) => void;
|
||||||
subscribeToProgress: () => Promise<UnlistenFn>;
|
subscribeToProgress: () => Promise<UnlistenFn>;
|
||||||
|
|
||||||
|
loadTaggerModelStatus: () => Promise<void>;
|
||||||
|
prepareTaggerModel: () => Promise<void>;
|
||||||
|
deleteTaggerModel: () => Promise<void>;
|
||||||
|
loadTaggerAcceleration: () => Promise<void>;
|
||||||
|
setTaggerAcceleration: (acceleration: TaggerAcceleration) => Promise<void>;
|
||||||
|
loadTaggerThreshold: () => Promise<void>;
|
||||||
|
setTaggerThreshold: (threshold: number) => Promise<void>;
|
||||||
|
probeTaggerRuntime: () => Promise<void>;
|
||||||
|
queueTaggingJobs: (folderId?: number | null) => Promise<number>;
|
||||||
|
queueTaggingForImage: (imageId: number) => Promise<number>;
|
||||||
|
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
|
||||||
|
getImageTags: (imageId: number) => Promise<ImageTag[]>;
|
||||||
|
addUserTag: (imageId: number, tag: string) => Promise<ImageTag>;
|
||||||
|
removeTag: (tagId: number) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 200;
|
const PAGE_SIZE = 200;
|
||||||
@@ -361,9 +444,20 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
captionModelProgress: null,
|
captionModelProgress: null,
|
||||||
captionRuntimeProbe: null,
|
captionRuntimeProbe: null,
|
||||||
captionRuntimeChecking: false,
|
captionRuntimeChecking: false,
|
||||||
|
captionAcceleration: "auto",
|
||||||
|
captionDetail: "paragraph",
|
||||||
aiCaptionsEnabled: initialAiCaptionsEnabled(),
|
aiCaptionsEnabled: initialAiCaptionsEnabled(),
|
||||||
settingsOpen: false,
|
settingsOpen: false,
|
||||||
|
|
||||||
|
taggerModelStatus: null,
|
||||||
|
taggerModelPreparing: false,
|
||||||
|
taggerModelError: null,
|
||||||
|
taggerModelProgress: null,
|
||||||
|
taggerAcceleration: "auto",
|
||||||
|
taggerThreshold: 0.35,
|
||||||
|
taggerRuntimeProbe: null,
|
||||||
|
taggerRuntimeChecking: false,
|
||||||
|
|
||||||
setCacheDir: (cacheDir) => set({ cacheDir }),
|
setCacheDir: (cacheDir) => set({ cacheDir }),
|
||||||
|
|
||||||
loadFolders: async () => {
|
loadFolders: async () => {
|
||||||
@@ -567,10 +661,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
similarSourceImageId: imageId,
|
similarSourceImageId: imageId,
|
||||||
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
|
galleryScrollResetKey: reset ? state.galleryScrollResetKey + 1 : state.galleryScrollResetKey,
|
||||||
}));
|
}));
|
||||||
try {
|
try {
|
||||||
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
const images = await invoke<ImageRecord[]>("find_similar_images", {
|
||||||
params: { image_id: imageId, limit: requestedLimit },
|
params: { image_id: imageId, folder_id: folderId ?? null, limit: requestedLimit },
|
||||||
});
|
});
|
||||||
const hasMore = images.length >= requestedLimit;
|
const hasMore = images.length >= requestedLimit;
|
||||||
set({
|
set({
|
||||||
images,
|
images,
|
||||||
@@ -616,6 +710,38 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loadCaptionAcceleration: async () => {
|
||||||
|
try {
|
||||||
|
const captionAcceleration = await invoke<CaptionAcceleration>("get_caption_acceleration");
|
||||||
|
set({ captionAcceleration });
|
||||||
|
} catch (error) {
|
||||||
|
set({ captionModelError: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCaptionAcceleration: async (acceleration) => {
|
||||||
|
const captionAcceleration = await invoke<CaptionAcceleration>("set_caption_acceleration", {
|
||||||
|
params: { acceleration },
|
||||||
|
});
|
||||||
|
set({ captionAcceleration, captionRuntimeProbe: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadCaptionDetail: async () => {
|
||||||
|
try {
|
||||||
|
const captionDetail = await invoke<CaptionDetail>("get_caption_detail");
|
||||||
|
set({ captionDetail });
|
||||||
|
} catch (error) {
|
||||||
|
set({ captionModelError: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setCaptionDetail: async (detail) => {
|
||||||
|
const captionDetail = await invoke<CaptionDetail>("set_caption_detail", {
|
||||||
|
params: { detail },
|
||||||
|
});
|
||||||
|
set({ captionDetail, captionRuntimeProbe: null });
|
||||||
|
},
|
||||||
|
|
||||||
prepareCaptionModel: async () => {
|
prepareCaptionModel: async () => {
|
||||||
set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null });
|
set({ captionModelPreparing: true, captionModelError: null, captionModelProgress: null });
|
||||||
try {
|
try {
|
||||||
@@ -683,6 +809,23 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
return queued;
|
return queued;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearCaptionJobs: async (folderId = get().selectedFolderId) => {
|
||||||
|
const cleared = await invoke<number>("clear_caption_jobs", {
|
||||||
|
params: { folder_id: folderId ?? null },
|
||||||
|
});
|
||||||
|
await get().loadBackgroundJobProgress();
|
||||||
|
return cleared;
|
||||||
|
},
|
||||||
|
|
||||||
|
resetGeneratedCaptions: async (folderId = get().selectedFolderId) => {
|
||||||
|
const reset = await invoke<number>("reset_generated_captions", {
|
||||||
|
params: { folder_id: folderId ?? null },
|
||||||
|
});
|
||||||
|
await get().loadBackgroundJobProgress();
|
||||||
|
await get().loadImages(true);
|
||||||
|
return reset;
|
||||||
|
},
|
||||||
|
|
||||||
setAiCaptionsEnabled: (aiCaptionsEnabled) => {
|
setAiCaptionsEnabled: (aiCaptionsEnabled) => {
|
||||||
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(aiCaptionsEnabled));
|
window.localStorage.setItem(AI_CAPTIONS_ENABLED_KEY, String(aiCaptionsEnabled));
|
||||||
set({ aiCaptionsEnabled });
|
set({ aiCaptionsEnabled });
|
||||||
@@ -690,6 +833,119 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
|
|
||||||
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
setSettingsOpen: (settingsOpen) => set({ settingsOpen }),
|
||||||
|
|
||||||
|
loadTaggerModelStatus: async () => {
|
||||||
|
try {
|
||||||
|
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
|
||||||
|
set({ taggerModelStatus, taggerModelError: null });
|
||||||
|
} catch (error) {
|
||||||
|
set({ taggerModelError: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadTaggerAcceleration: async () => {
|
||||||
|
try {
|
||||||
|
const taggerAcceleration = await invoke<TaggerAcceleration>("get_tagger_acceleration");
|
||||||
|
set({ taggerAcceleration });
|
||||||
|
} catch (error) {
|
||||||
|
set({ taggerModelError: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTaggerAcceleration: async (acceleration) => {
|
||||||
|
const taggerAcceleration = await invoke<TaggerAcceleration>("set_tagger_acceleration", {
|
||||||
|
params: { acceleration },
|
||||||
|
});
|
||||||
|
set({ taggerAcceleration, taggerRuntimeProbe: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
loadTaggerThreshold: async () => {
|
||||||
|
try {
|
||||||
|
const taggerThreshold = await invoke<number>("get_tagger_threshold");
|
||||||
|
set({ taggerThreshold });
|
||||||
|
} catch (error) {
|
||||||
|
set({ taggerModelError: String(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setTaggerThreshold: async (threshold) => {
|
||||||
|
const taggerThreshold = await invoke<number>("set_tagger_threshold", {
|
||||||
|
params: { threshold },
|
||||||
|
});
|
||||||
|
set({ taggerThreshold });
|
||||||
|
},
|
||||||
|
|
||||||
|
prepareTaggerModel: async () => {
|
||||||
|
set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null });
|
||||||
|
try {
|
||||||
|
const taggerModelStatus = await invoke<TaggerModelStatus>("prepare_tagger_model");
|
||||||
|
set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null });
|
||||||
|
} catch (error) {
|
||||||
|
set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteTaggerModel: async () => {
|
||||||
|
set({ taggerModelPreparing: true, taggerModelError: null, taggerModelProgress: null });
|
||||||
|
try {
|
||||||
|
const taggerModelStatus = await invoke<TaggerModelStatus>("delete_tagger_model");
|
||||||
|
set({ taggerModelStatus, taggerModelPreparing: false, taggerModelError: null, taggerModelProgress: null, taggerRuntimeProbe: null });
|
||||||
|
} catch (error) {
|
||||||
|
set({ taggerModelPreparing: false, taggerModelError: String(error), taggerModelProgress: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
probeTaggerRuntime: async () => {
|
||||||
|
set({ taggerRuntimeChecking: true, taggerModelError: null });
|
||||||
|
try {
|
||||||
|
const taggerRuntimeProbe = await invoke<TaggerRuntimeProbe>("probe_tagger_runtime");
|
||||||
|
set({ taggerRuntimeProbe, taggerRuntimeChecking: false, taggerModelError: null });
|
||||||
|
} catch (error) {
|
||||||
|
set({ taggerRuntimeChecking: false, taggerModelError: String(error), taggerRuntimeProbe: null });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
queueTaggingJobs: async (folderId = get().selectedFolderId) => {
|
||||||
|
const queued = await invoke<number>("queue_tagging_jobs", {
|
||||||
|
params: { folder_id: folderId ?? null, image_id: null },
|
||||||
|
});
|
||||||
|
await get().loadBackgroundJobProgress();
|
||||||
|
return queued;
|
||||||
|
},
|
||||||
|
|
||||||
|
queueTaggingForImage: async (imageId) => {
|
||||||
|
const queued = await invoke<number>("queue_tagging_jobs", {
|
||||||
|
params: { folder_id: null, image_id: imageId },
|
||||||
|
});
|
||||||
|
await get().loadBackgroundJobProgress();
|
||||||
|
return queued;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearTaggingJobs: async (folderId = get().selectedFolderId) => {
|
||||||
|
const cleared = await invoke<number>("clear_tagging_jobs", {
|
||||||
|
params: { folder_id: folderId ?? null },
|
||||||
|
});
|
||||||
|
await get().loadBackgroundJobProgress();
|
||||||
|
return cleared;
|
||||||
|
},
|
||||||
|
|
||||||
|
getImageTags: async (imageId) => {
|
||||||
|
return invoke<ImageTag[]>("get_image_tags", {
|
||||||
|
params: { image_id: imageId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
addUserTag: async (imageId, tag) => {
|
||||||
|
return invoke<ImageTag>("add_user_tag", {
|
||||||
|
params: { image_id: imageId, tag },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTag: async (tagId) => {
|
||||||
|
await invoke<void>("remove_tag", {
|
||||||
|
params: { tag_id: tagId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
retryFailedEmbeddings: async (folderId) => {
|
retryFailedEmbeddings: async (folderId) => {
|
||||||
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
|
await invoke("retry_failed_embeddings", { params: { folder_id: folderId } });
|
||||||
await get().loadBackgroundJobProgress();
|
await get().loadBackgroundJobProgress();
|
||||||
@@ -752,6 +1008,13 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const unlistenTaggerModelProgress = await listen<TaggerModelProgress>("tagger-model-progress", (event) => {
|
||||||
|
set({
|
||||||
|
taggerModelProgress: event.payload.done ? null : event.payload,
|
||||||
|
taggerModelPreparing: !event.payload.done,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
const unlistenImages = await listen<IndexedImagesBatch>("indexed-images", (event) => {
|
||||||
const batch = event.payload;
|
const batch = event.payload;
|
||||||
|
|
||||||
@@ -817,6 +1080,7 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
unlistenProgress();
|
unlistenProgress();
|
||||||
unlistenMediaJobs();
|
unlistenMediaJobs();
|
||||||
unlistenCaptionModelProgress();
|
unlistenCaptionModelProgress();
|
||||||
|
unlistenTaggerModelProgress();
|
||||||
unlistenImages();
|
unlistenImages();
|
||||||
unlistenThumbnails();
|
unlistenThumbnails();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user