From 4cdbc54d1855eae282528a7a25ed000325ab2825 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 1 Jul 2026 10:31:22 +0100 Subject: [PATCH] feat(ai-tags): add reset flow Add a reset_ai_tags backend command that removes AI-generated tag rows, clears AI tagging metadata, cancels active tagging jobs, and drops queued or failed tagging jobs in the selected scope. Expose reset actions in AI Workspace and the Explore tag manager, refresh gallery/progress/tag state after reset, and add subtle AI source indicators to tag manager rows. --- src-tauri/src/commands.rs | 42 ++++++++ src-tauri/src/db.rs | 118 +++++++++++++++++++++- src-tauri/src/lib.rs | 1 + src/components/ExploreView.tsx | 166 ++++++++++++++++++++++++++++--- src/components/SettingsModal.tsx | 71 ++++++++++++- src/dev/mockBackend.ts | 58 +++++++++++ src/dev/mockFixtures.ts | 3 + src/index.css | 41 ++++++++ src/store.ts | 24 +++++ 9 files changed, 505 insertions(+), 19 deletions(-) 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 },