diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4b00dc3..9a80c58 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2179,6 +2179,12 @@ pub struct ClearTaggingJobsParams { pub folder_ids: Option>, } +#[derive(Deserialize)] +pub struct ResetAiTagsParams { + pub folder_id: Option, + pub folder_ids: Option>, +} + #[derive(Deserialize)] pub struct GetImageTagsParams { pub image_id: i64, @@ -2377,6 +2383,42 @@ pub async fn clear_tagging_jobs( Ok(n) } +#[tauri::command] +pub async fn reset_ai_tags( + app: AppHandle, + db: State<'_, DbState>, + params: ResetAiTagsParams, +) -> Result { + let conn = db.get().map_err(|e| e.to_string())?; + let requested_folder_ids = params.folder_ids.unwrap_or_default(); + let (n, folder_ids): (usize, Vec) = + match (params.folder_id, requested_folder_ids.is_empty()) { + (Some(id), _) => ( + db::reset_ai_tags(&conn, Some(id)).map_err(|e| e.to_string())?, + vec![id], + ), + (None, false) => { + let mut total = 0usize; + for &folder_id in &requested_folder_ids { + total += db::reset_ai_tags(&conn, Some(folder_id)) + .map_err(|e| e.to_string())?; + } + (total, requested_folder_ids) + } + (None, true) => ( + db::reset_ai_tags(&conn, None).map_err(|e| e.to_string())?, + db::get_folders(&conn) + .map_err(|e| e.to_string())? + .into_iter() + .map(|f| f.id) + .collect(), + ), + }; + drop(conn); + indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true); + Ok(n) +} + #[tauri::command] pub async fn get_image_tags( db: State<'_, DbState>, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 8249ef0..d486fa3 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -146,6 +146,8 @@ pub struct ExploreTagEntry { pub count: i64, pub representative_image_id: i64, pub thumbnail_path: Option, + pub has_ai_source: bool, + pub has_user_source: bool, } #[derive(Debug, Clone)] @@ -2341,7 +2343,9 @@ pub fn search_tags_autocomplete( ) -> Result> { let pattern = format!("%{}%", query.to_lowercase()); let mut stmt = conn.prepare( - "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id + "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, + MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, + MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source FROM image_tags t JOIN images i ON i.id = t.image_id WHERE (?1 IS NULL OR i.folder_id = ?1) @@ -2357,6 +2361,8 @@ pub fn search_tags_autocomplete( count: row.get(1)?, representative_image_id: row.get(2)?, thumbnail_path: None, // skip per-suggestion thumbnail for speed + has_ai_source: row.get::<_, i64>(3)? != 0, + has_user_source: row.get::<_, i64>(4)? != 0, }) })? .collect::>>()?; @@ -2440,7 +2446,9 @@ pub fn get_explore_tags( limit: usize, ) -> Result> { let mut stmt = conn.prepare( - "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id + "SELECT t.tag, COUNT(DISTINCT t.image_id) AS tag_count, MIN(t.image_id) AS representative_image_id, + MAX(CASE WHEN t.source = 'ai' THEN 1 ELSE 0 END) AS has_ai_source, + MAX(CASE WHEN t.source = 'user' THEN 1 ELSE 0 END) AS has_user_source FROM image_tags t JOIN images i ON i.id = t.image_id WHERE (?1 IS NULL OR i.folder_id = ?1) @@ -2462,6 +2470,8 @@ pub fn get_explore_tags( count: row.get(1)?, representative_image_id, thumbnail_path, + has_ai_source: row.get::<_, i64>(3)? != 0, + has_user_source: row.get::<_, i64>(4)? != 0, }) })? .collect::>>()?; @@ -2848,6 +2858,110 @@ pub fn delete_tag(conn: &Connection, name: &str) -> Result { Ok(removed) } +pub fn reset_ai_tags(conn: &Connection, folder_id: Option) -> Result { + let image_ids = match folder_id { + Some(folder_id) => { + let mut stmt = conn.prepare( + "SELECT DISTINCT i.id + FROM images i + LEFT JOIN image_tags t ON t.image_id = i.id AND t.source = 'ai' + LEFT JOIN tagging_jobs j ON j.image_id = i.id + WHERE i.folder_id = ?1 + AND i.media_kind = 'image' + AND ( + t.id IS NOT NULL + OR i.ai_rating IS NOT NULL + OR i.ai_tagger_model IS NOT NULL + OR i.ai_tagged_at IS NOT NULL + OR i.ai_tagger_error IS NOT NULL + OR j.image_id IS NOT NULL + )", + )?; + let rows = stmt.query_map([folder_id], |row| row.get::<_, i64>(0))?; + rows.collect::>>()? + } + None => { + let mut stmt = conn.prepare( + "SELECT DISTINCT i.id + FROM images i + LEFT JOIN image_tags t ON t.image_id = i.id AND t.source = 'ai' + LEFT JOIN tagging_jobs j ON j.image_id = i.id + WHERE i.media_kind = 'image' + AND ( + t.id IS NOT NULL + OR i.ai_rating IS NOT NULL + OR i.ai_tagger_model IS NOT NULL + OR i.ai_tagged_at IS NOT NULL + OR i.ai_tagger_error IS NOT NULL + OR j.image_id IS NOT NULL + )", + )?; + let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; + rows.collect::>>()? + } + }; + + let tx = conn.unchecked_transaction()?; + match folder_id { + Some(folder_id) => { + tx.execute( + "DELETE FROM image_tags + WHERE source = 'ai' + AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)", + [folder_id], + )?; + tx.execute( + "UPDATE images + SET ai_rating = NULL, + ai_tagger_model = NULL, + ai_tagged_at = NULL, + ai_tagger_error = NULL + WHERE folder_id = ?1 + AND media_kind = 'image'", + [folder_id], + )?; + tx.execute( + "UPDATE tagging_jobs + SET status = 'cancelled', last_error = NULL, updated_at = datetime('now') + WHERE status = 'processing' + AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)", + [folder_id], + )?; + tx.execute( + "DELETE FROM tagging_jobs + WHERE status NOT IN ('processing', 'cancelled') + AND image_id IN (SELECT id FROM images WHERE folder_id = ?1)", + [folder_id], + )?; + } + None => { + tx.execute("DELETE FROM image_tags WHERE source = 'ai'", [])?; + tx.execute( + "UPDATE images + SET ai_rating = NULL, + ai_tagger_model = NULL, + ai_tagged_at = NULL, + ai_tagger_error = NULL + WHERE media_kind = 'image'", + [], + )?; + tx.execute( + "UPDATE tagging_jobs + SET status = 'cancelled', last_error = NULL, updated_at = datetime('now') + WHERE status = 'processing'", + [], + )?; + tx.execute( + "DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')", + [], + )?; + } + } + tx.execute("DELETE FROM visual_cluster_cache", [])?; + tx.commit()?; + Ok(image_ids.len()) +} + pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result { let inserted = conn.execute( "INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5489d92..706012a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ pub fn run() { commands::delete_tagger_model, commands::queue_tagging_jobs, commands::clear_tagging_jobs, + commands::reset_ai_tags, commands::get_image_tags, commands::add_user_tag, commands::remove_tag, diff --git a/src/components/ExploreView.tsx b/src/components/ExploreView.tsx index cb7c7cc..76a522b 100644 --- a/src/components/ExploreView.tsx +++ b/src/components/ExploreView.tsx @@ -678,6 +678,51 @@ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [ { value: "za", label: "Z-A" }, ]; +function AiSourceGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +function RenameGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + +function DeleteGlyph({ className = "" }: { className?: string }) { + return ( + + ); +} + // Compact management tile for a single tag. Rename doubles as merge when the new // name already exists, and delete applies across the scoped tag set. function TagManageTile({ @@ -719,12 +764,20 @@ function TagManageTile({ } }; + const sourceLabel = entry.has_ai_source + ? entry.has_user_source + ? "Used by AI tags and user tags" + : "AI-generated tag" + : "User tag"; + return (
{editing ? ( @@ -749,9 +802,20 @@ function TagManageTile({ )} - - {entry.count.toLocaleString()} - +
+ {entry.has_ai_source ? ( + + + + {entry.count.toLocaleString()} + + + ) : ( + + {entry.count.toLocaleString()} + + )} +
{editing ? ( @@ -789,21 +853,25 @@ function TagManageTile({
) : ( -
+
+ + + -
)}
@@ -815,17 +883,24 @@ function TagManageList({ onSearch, onRename, onDelete, + onResetAiTags, + scopeLabel, }: { entries: ExploreTagEntry[]; onSearch: (tag: string) => void; onRename: (from: string, to: string) => Promise; onDelete: (tag: string) => Promise; + onResetAiTags: () => Promise; + scopeLabel: string; }) { const scrollRef = useRef(null); const measureRef = useRef(null); const [query, setQuery] = useState(""); const [sort, setSort] = useState("count_desc"); const [columns, setColumns] = useState(3); + const [resetConfirming, setResetConfirming] = useState(false); + const [resetting, setResetting] = useState(false); + const [resetStatus, setResetStatus] = useState(null); useLayoutEffect(() => { const el = measureRef.current; @@ -870,6 +945,30 @@ function TagManageList({ const visibleItems = rowVirtualizer.getVirtualItems(); const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]); + const runResetAiTags = async () => { + if (!resetConfirming) { + setResetConfirming(true); + setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`); + return; + } + + setResetting(true); + setResetStatus(null); + try { + const count = await onResetAiTags(); + setResetStatus( + count === 0 + ? "No AI tag data found for this scope." + : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, + ); + } catch (error) { + setResetStatus(String(error)); + } finally { + setResetting(false); + setResetConfirming(false); + } + }; + return (
@@ -887,9 +986,33 @@ function TagManageList({

Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.

+ {resetStatus ?

{resetStatus}

: null}
+ + {resetConfirming ? ( + + ) : null}
state.searchForTag); const renameTag = useGalleryStore((state) => state.renameTag); const deleteTag = useGalleryStore((state) => state.deleteTag); + const resetAiTags = useGalleryStore((state) => state.resetAiTags); + const folders = useGalleryStore((state) => state.folders); const selectedFolderId = useGalleryStore((state) => state.selectedFolderId); // Manage mode lives in the store so it can be opened from elsewhere (Settings). const manageTags = useGalleryStore((state) => state.tagManagerOpen); const setManageTags = useGalleryStore((state) => state.setTagManagerOpen); const handleDeleteTag = async (tag: string) => { await deleteTag(tag); }; + const tagManagerScopeLabel = + selectedFolderId === null + ? "all media" + : folders.find((folder) => folder.id === selectedFolderId)?.name ?? "the current folder"; + const handleResetAiTags = async () => { + const count = await resetAiTags(selectedFolderId); + await loadExploreTags({ force: true }); + return count; + }; useEffect(() => { if (exploreMode === "visual") void loadVisualClusters(); @@ -1080,6 +1214,8 @@ export function ExploreView() { onSearch={searchForTag} onRename={renameTag} onDelete={handleDeleteTag} + onResetAiTags={handleResetAiTags} + scopeLabel={tagManagerScopeLabel} />
) : ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e1ff9d8..b7556cc 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -166,6 +166,8 @@ export function SettingsModal() { const [taggerQueueStatus, setTaggerQueueStatus] = useState(null); const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false); + const [taggerResetConfirming, setTaggerResetConfirming] = useState(false); + const [taggerResetting, setTaggerResetting] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); const [taggerAccelerationError, setTaggerAccelerationError] = useState(null); const [taggerModelSwitching, setTaggerModelSwitching] = useState(false); @@ -226,6 +228,8 @@ export function SettingsModal() { const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); + const resetAiTags = useGalleryStore((state) => state.resetAiTags); + const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders); const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const notificationsPaused = useGalleryStore((state) => state.notificationsPaused); const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused); @@ -341,6 +345,7 @@ export function SettingsModal() { setTaggerClearing(true); } setTaggerQueueStatus(null); + setTaggerResetConfirming(false); void perform .then((count) => { @@ -368,6 +373,45 @@ export function SettingsModal() { }); }; + const runResetAiTags = () => { + if (!taggerResetConfirming) { + setTaggerResetConfirming(true); + setTaggerQueueStatus( + `Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`, + ); + return; + } + + const selectedIds = taggingQueueFolderIds; + const perform = + taggingQueueScope === "all" + ? resetAiTags(null) + : selectedIds.length > 0 + ? resetAiTagsForFolders(selectedIds) + : Promise.resolve(0); + + setTaggerResetting(true); + setTaggerQueueStatus(null); + + void perform + .then((count) => { + if (taggingQueueScope === "selected" && selectedIds.length === 0) { + setTaggerQueueStatus("Choose at least one folder before resetting AI tags."); + return; + } + setTaggerQueueStatus( + count === 0 + ? "No AI tag data found for the current target." + : `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`, + ); + }) + .catch((error) => setTaggerQueueStatus(String(error))) + .finally(() => { + setTaggerResetting(false); + setTaggerResetConfirming(false); + }); + }; + return (
setSettingsOpen(false)}>
runQueueAction("queue")} - disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} + disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)} > {taggerQueueing ? "Queueing..." : "Queue tagging"} + + {taggerResetConfirming ? ( + + ) : null}
diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 7269d9c..404620b 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -106,6 +106,38 @@ function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] { .slice(0, limit); } +function rebuildExploreTags(): ExploreTagEntry[] { + const entries = new Map< + string, + { imageIds: Set; representativeImageId: number; hasAiSource: boolean; hasUserSource: boolean } + >(); + for (const [imageIdString, imageTags] of Object.entries(db.tagsByImageId)) { + const imageId = Number(imageIdString); + for (const imageTag of imageTags) { + const entry = + entries.get(imageTag.tag) ?? + { imageIds: new Set(), representativeImageId: imageId, hasAiSource: false, hasUserSource: false }; + entry.imageIds.add(imageId); + if (imageTag.source === "ai") entry.hasAiSource = true; + if (imageTag.source === "user") entry.hasUserSource = true; + entries.set(imageTag.tag, entry); + } + } + return [...entries.entries()] + .map(([tag, entry]) => { + const representative = db.images.find((image) => image.id === entry.representativeImageId); + return { + tag, + count: entry.imageIds.size, + representative_image_id: entry.representativeImageId, + thumbnail_path: representative?.thumbnail_path ?? null, + has_ai_source: entry.hasAiSource, + has_user_source: entry.hasUserSource, + }; + }) + .sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); +} + function semanticSearch(payload: unknown): ImageRecord[] { const p = params(payload); const query = String(p.query ?? "").toLowerCase(); @@ -400,6 +432,32 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "queue_tagging_jobs": case "clear_tagging_jobs": return 12; + case "reset_ai_tags": { + const folderIds = p.folder_ids as number[] | undefined; + const folderId = p.folder_id as number | null | undefined; + const scopedImages = db.images.filter((image) => + folderIds && folderIds.length > 0 + ? folderIds.includes(image.folder_id) + : folderId != null + ? image.folder_id === folderId + : true, + ); + let reset = 0; + for (const image of scopedImages) { + const tags = db.tagsByImageId[image.id] ?? []; + const aiTags = tags.filter((tag) => tag.source === "ai"); + if (aiTags.length > 0 || image.ai_tagged_at || image.ai_tagger_error || image.ai_tagger_model || image.ai_rating) { + reset += 1; + } + db.tagsByImageId[image.id] = tags.filter((tag) => tag.source !== "ai"); + image.ai_rating = null; + image.ai_tagger_model = null; + image.ai_tagged_at = null; + image.ai_tagger_error = null; + } + db.exploreTags = rebuildExploreTags(); + return reset; + } case "get_tagger_model_status": return { model_id: "SmilingWolf/wd-vit-tagger-v3", model_name: "WD ViT Tagger", local_dir: "mock://models/tagger", ready: true, missing_files: [] }; case "get_tagger_model": diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index c4abb06..846d895 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -280,11 +280,14 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore return vocabulary.map((tag, index) => { const representative = images[index % Math.max(images.length, 1)]; const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3; + const sourceCycle = index % 3; return { tag, count: Math.max(1, Math.round(images.length / divisor)), representative_image_id: representative?.id ?? index + 1, thumbnail_path: representative?.thumbnail_path ?? null, + has_ai_source: sourceCycle !== 1, + has_user_source: sourceCycle !== 0, }; }).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag)); } diff --git a/src/index.css b/src/index.css index c353603..4a8c44c 100644 --- a/src/index.css +++ b/src/index.css @@ -259,6 +259,33 @@ html[data-theme="subtle-light"] .tag-manager-filter:focus { border-color: #7aa2e3 !important; } +html[data-theme="subtle-light"] .tag-manager-reset-button, +html[data-theme="subtle-light"] .tag-manager-reset-cancel { + background: #ebe8df !important; + border-color: #c9c1b4 !important; + color: #5f5a52 !important; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.58) !important; +} + +html[data-theme="subtle-light"] .tag-manager-reset-button:hover, +html[data-theme="subtle-light"] .tag-manager-reset-cancel:hover { + background: #e2ded3 !important; + border-color: #b9b0a2 !important; + color: #24201b !important; +} + +html[data-theme="subtle-light"] .tag-manager-reset-button-confirm { + background: rgb(254 242 242 / 0.95) !important; + border-color: rgb(220 38 38 / 0.34) !important; + color: #991b1b !important; +} + +html[data-theme="subtle-light"] .tag-manager-reset-button-confirm:hover { + background: #fee2e2 !important; + border-color: rgb(220 38 38 / 0.48) !important; + color: #7f1d1d !important; +} + html[data-theme="subtle-light"] .tag-manager-clear { color: #6b645a !important; } @@ -292,6 +319,20 @@ html[data-theme="subtle-light"] .tag-manager-count { color: #6b645a !important; } +html[data-theme="subtle-light"] .tag-manager-tile-ai { + box-shadow: inset 0 1px 0 rgb(14 116 144 / 0.08) !important; +} + +html[data-theme="subtle-light"] .tag-manager-count-ai { + background: rgb(14 116 144 / 0.1) !important; + border-color: rgb(14 116 144 / 0.18) !important; + color: #0d5f6f !important; +} + +html[data-theme="subtle-light"] .tag-manager-ai-glyph { + color: #0d5f6f !important; +} + html[data-theme="subtle-light"] .tag-manager-edit-input { background: #fffdfa !important; color: #111827 !important; diff --git a/src/store.ts b/src/store.ts index f97cd6a..36ae2da 100644 --- a/src/store.ts +++ b/src/store.ts @@ -215,6 +215,8 @@ export interface ExploreTagEntry { count: number; representative_image_id: number; thumbnail_path: string | null; + has_ai_source: boolean; + has_user_source: boolean; } export interface RelatedTagEntry { @@ -585,6 +587,8 @@ interface GalleryState { queueTaggingForImage: (imageId: number) => Promise; clearTaggingJobs: (folderId?: number | null) => Promise; clearTaggingJobsForFolders: (folderIds: number[]) => Promise; + resetAiTags: (folderId?: number | null) => Promise; + resetAiTagsForFolders: (folderIds: number[]) => Promise; loadDuplicateScanCache: (folderId?: number | null) => Promise; scanDuplicates: (folderId?: number | null) => Promise; toggleDuplicateSelected: (imageId: number) => void; @@ -2383,6 +2387,26 @@ export const useGalleryStore = create((set, get) => ({ return cleared; }, + resetAiTags: async (folderId = get().selectedFolderId) => { + const reset = await invoke("reset_ai_tags", { + params: { folder_id: folderId ?? null, folder_ids: null }, + }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + + resetAiTagsForFolders: async (folderIds) => { + const reset = await invoke("reset_ai_tags", { + params: { folder_id: null, folder_ids: folderIds }, + }); + set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] }); + await get().loadBackgroundJobProgress(); + await get().loadImages(true); + return reset; + }, + getImageTags: async (imageId) => { return invoke("get_image_tags", { params: { image_id: imageId },