From 4cdbc54d1855eae282528a7a25ed000325ab2825 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 1 Jul 2026 10:31:22 +0100 Subject: [PATCH 1/2] 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 }, From a78111c8d4680a60e4124f9cd7bc497ee744cd9f Mon Sep 17 00:00:00 2001 From: LyAhn Date: Wed, 1 Jul 2026 10:31:59 +0100 Subject: [PATCH 2/2] test(ui-lab): add extreme mock scenario Add an extreme UI Lab scenario with virtual-scale library, album, cluster, and tag counts while keeping the rendered media fixture set manageable. Return the full extreme tag set from the mock backend so tag manager layouts can be stress-tested with 10k-100k tag counts. --- src/dev/mockBackend.ts | 8 +++-- src/dev/mockFixtures.ts | 69 ++++++++++++++++++++++++++++++---------- src/dev/mockScenarios.ts | 3 +- 3 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/dev/mockBackend.ts b/src/dev/mockBackend.ts index 404620b..d55ac27 100644 --- a/src/dev/mockBackend.ts +++ b/src/dev/mockBackend.ts @@ -206,7 +206,7 @@ function updateImages(ids: number[], updates: { favorite?: boolean | null; ratin function refreshAlbumCounts() { for (const album of db.albums) { const ids = db.albumImageIds[album.id] ?? []; - album.image_count = ids.length; + if (db.scenario !== "extreme") album.image_count = ids.length; album.cover_image_id = ids[0] ?? null; album.cover_thumbnail_path = db.images.find((image) => image.id === ids[0])?.thumbnail_path ?? null; album.updated_at = new Date().toISOString(); @@ -317,7 +317,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise case "get_visual_clusters": return db.scenario === "empty" ? [] : db.visualClusters; case "get_explore_tags": - return db.scenario === "empty" ? [] : db.exploreTags.slice(0, Number(p.limit ?? 180)); + return db.scenario === "empty" + ? [] + : db.scenario === "extreme" + ? db.exploreTags + : db.exploreTags.slice(0, Number(p.limit ?? 180)); case "get_related_tags": { const relatedCounts = new Map(); for (const imageTags of Object.values(db.tagsByImageId)) { diff --git a/src/dev/mockFixtures.ts b/src/dev/mockFixtures.ts index 846d895..f966c76 100644 --- a/src/dev/mockFixtures.ts +++ b/src/dev/mockFixtures.ts @@ -29,6 +29,17 @@ export interface MockDb { } const folderNames = ["Camera Roll", "Client Selects", "Video Archive"]; +const extremeFolderNames = [ + "Camera Roll", + "Client Selects", + "Video Archive", + "Commercial Library", + "Archive Vault", + "Reference Pulls", + "Delivery Selects", + "Personal Work", +]; +const extremeFolderCounts = [98_420, 76_880, 52_340, 44_900, 31_760, 24_180, 16_540, 12_920]; const mediaNames = [ "alpine-lake", "city-after-rain", @@ -119,6 +130,16 @@ const hugeTags = [ }), ...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`), ]; +const extremeTags = [ + ...tags, + ...Array.from({ length: hugeTagThemes.length * hugeTagSubjects.length }, (_, index) => { + const theme = hugeTagThemes[index % hugeTagThemes.length]; + const subject = hugeTagSubjects[Math.floor(index / hugeTagThemes.length) % hugeTagSubjects.length]; + return `${theme} ${subject}`; + }), + ...Array.from({ length: 780 }, (_, index) => `extreme concept ${String(index + 1).padStart(4, "0")}`), + ...Array.from({ length: 240 }, (_, index) => `client taxonomy ${String(index + 1).padStart(3, "0")}`), +]; const aiRatings: AiRating[] = ["general", "sensitive", "questionable"]; function daysAgo(days: number): string { @@ -177,7 +198,8 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima function makeFolders(scenario: MockScenario): Folder[] { if (scenario === "empty") return []; - return folderNames.map((name, index) => ({ + const names = scenario === "extreme" ? extremeFolderNames : folderNames; + return names.map((name, index) => ({ id: index + 1, path: `C:\\Users\\phokus\\Pictures\\${name.replace(/ /g, "")}`, name, @@ -190,21 +212,25 @@ function makeFolders(scenario: MockScenario): Folder[] { function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] { if (scenario === "empty") return []; - const count = scenario === "huge" ? 720 : scenario === "errors" ? 54 : 72; + const count = scenario === "extreme" ? 1_440 : scenario === "huge" ? 720 : scenario === "errors" ? 54 : 72; const images = Array.from({ length: count }, (_, index) => makeImage(index, folders[index % folders.length].id, scenario)); for (const folder of folders) { - folder.image_count = images.filter((image) => image.folder_id === folder.id).length; + folder.image_count = + scenario === "extreme" + ? extremeFolderCounts[folder.id - 1] ?? images.filter((image) => image.folder_id === folder.id).length + : images.filter((image) => image.folder_id === folder.id).length; } return images; } function tagVocabularyForScenario(scenario: MockScenario): string[] { + if (scenario === "extreme") return extremeTags; return scenario === "huge" ? hugeTags : tags; } function makeTags(images: ImageRecord[], scenario: MockScenario): Record { const vocabulary = tagVocabularyForScenario(scenario); - const tagCount = scenario === "huge" ? 9 : 3; + const tagCount = scenario === "extreme" ? 14 : scenario === "huge" ? 9 : 3; return Object.fromEntries( images.map((image, index) => [ image.id, @@ -227,10 +253,13 @@ function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Al const motion = images.filter((image) => image.media_kind === "video").map((image) => image.id); const favorites = images.filter((image) => image.favorite).slice(0, 40).map((image) => image.id); const albumImageIds = { 1: selects, 2: motion, 3: favorites }; + const portfolioCount = scenario === "extreme" ? 18_450 : selects.length; + const motionCount = scenario === "extreme" ? 7_820 : motion.length; + const favoritesCount = scenario === "extreme" ? 42_610 : favorites.length; const albums: Album[] = [ - { id: 1, name: "Portfolio Shortlist", cover_image_id: selects[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === selects[0])?.thumbnail_path ?? null, image_count: selects.length, sort_order: 1, created_at: daysAgo(40), updated_at: daysAgo(2) }, - { id: 2, name: "Motion Tests", cover_image_id: motion[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === motion[0])?.thumbnail_path ?? null, image_count: motion.length, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) }, - { id: 3, name: "Favorites", cover_image_id: favorites[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === favorites[0])?.thumbnail_path ?? null, image_count: favorites.length, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) }, + { id: 1, name: "Portfolio Shortlist", cover_image_id: selects[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === selects[0])?.thumbnail_path ?? null, image_count: portfolioCount, sort_order: 1, created_at: daysAgo(40), updated_at: daysAgo(2) }, + { id: 2, name: "Motion Tests", cover_image_id: motion[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === motion[0])?.thumbnail_path ?? null, image_count: motionCount, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) }, + { id: 3, name: "Favorites", cover_image_id: favorites[0] ?? null, cover_thumbnail_path: images.find((image) => image.id === favorites[0])?.thumbnail_path ?? null, image_count: favoritesCount, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) }, ]; return { albums, albumImageIds }; } @@ -238,15 +267,15 @@ function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Al function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgress[] { return folders.map((folder, index) => ({ folder_id: folder.id, - thumbnail_pending: scenario === "busy" ? 18 - index * 3 : 0, - metadata_pending: scenario === "busy" ? 12 - index * 2 : 0, - embedding_pending: scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 0, + thumbnail_pending: scenario === "extreme" ? 180 - index * 9 : scenario === "busy" ? 18 - index * 3 : 0, + metadata_pending: scenario === "extreme" ? 140 - index * 7 : scenario === "busy" ? 12 - index * 2 : 0, + embedding_pending: scenario === "extreme" ? 260 - index * 11 : scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 0, embedding_ready: Math.max(0, folder.image_count - (scenario === "busy" ? 20 : 2)), embedding_failed: scenario === "errors" ? 3 + index : 0, - caption_pending: scenario === "busy" ? 24 - index * 2 : 0, + caption_pending: scenario === "extreme" ? 210 - index * 10 : scenario === "busy" ? 24 - index * 2 : 0, caption_ready: Math.max(0, Math.floor(folder.image_count * 0.5)), caption_failed: scenario === "errors" ? 2 : 0, - tagging_pending: scenario === "busy" ? 30 - index * 5 : 0, + tagging_pending: scenario === "extreme" ? 320 - index * 13 : scenario === "busy" ? 30 - index * 5 : 0, tagging_ready: Math.max(0, Math.floor(folder.image_count * 0.65)), tagging_failed: scenario === "errors" ? 4 : 0, })); @@ -258,8 +287,8 @@ function makeVisualClusters(images: ImageRecord[], scenario: MockScenario): Visu // "huge" scenario so Explore shows a realistic field of clusters rather than a // fixed handful, and size the counts off a large virtual library so the badges // read like a big collection. - const clusterCount = scenario === "huge" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20))); - const virtualTotal = scenario === "huge" ? 12_000 : images.length; + const clusterCount = scenario === "huge" || scenario === "extreme" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20))); + const virtualTotal = scenario === "extreme" ? 250_000 : scenario === "huge" ? 12_000 : images.length; // Long-tailed sizes (a few large clusters, many smaller), like real k-means output. const weights = Array.from({ length: clusterCount }, (_, i) => 1 / (i + 1.5)); const weightSum = weights.reduce((sum, weight) => sum + weight, 0); @@ -279,11 +308,19 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore const vocabulary = tagVocabularyForScenario(scenario); return vocabulary.map((tag, index) => { const representative = images[index % Math.max(images.length, 1)]; - const divisor = scenario === "huge" ? 2 + Math.pow(index + 1, 0.58) : index + 3; + const divisor = + scenario === "extreme" + ? 1 + Math.pow(index + 1, 0.42) + : 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)), + count: + scenario === "extreme" + ? Math.max(10_000, Math.min(100_000, Math.round(200_000 / divisor))) + : 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, diff --git a/src/dev/mockScenarios.ts b/src/dev/mockScenarios.ts index b78a016..db7d36f 100644 --- a/src/dev/mockScenarios.ts +++ b/src/dev/mockScenarios.ts @@ -1,4 +1,4 @@ -export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge"; +export type MockScenario = "rich" | "empty" | "busy" | "duplicates" | "album" | "errors" | "huge" | "extreme"; const SCENARIOS = new Set([ "rich", @@ -8,6 +8,7 @@ const SCENARIOS = new Set([ "album", "errors", "huge", + "extreme", ]); export function getMockScenario(): MockScenario {