diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1a48de9..de4f768 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -38,6 +38,7 @@ pub struct GetImagesParams { pub favorites_only: Option, pub rating_min: Option, pub embedding_failed_only: Option, + pub tagging_failed_only: Option, pub sort: Option, pub offset: Option, pub limit: Option, @@ -329,6 +330,7 @@ pub async fn get_images( let favorites_only = params.favorites_only.unwrap_or(false); let rating_min = params.rating_min.unwrap_or(0); let embedding_failed_only = params.embedding_failed_only.unwrap_or(false); + let tagging_failed_only = params.tagging_failed_only.unwrap_or(false); let total = db::count_images( &conn, @@ -338,6 +340,7 @@ pub async fn get_images( favorites_only, rating_min, embedding_failed_only, + tagging_failed_only, ) .map_err(|e| e.to_string())?; @@ -349,6 +352,7 @@ pub async fn get_images( favorites_only, rating_min, embedding_failed_only, + tagging_failed_only, sort, offset, limit, @@ -594,6 +598,34 @@ pub async fn retry_failed_embeddings( db::retry_failed_embedding_jobs(&conn, params.folder_id).map_err(|e| e.to_string()) } +#[derive(Serialize)] +pub struct FailedImageItem { + pub image_id: i64, + pub filename: String, + pub path: String, + pub error: Option, +} + +#[tauri::command] +pub async fn get_failed_tagging_images( + db: State<'_, DbState>, + folder_id: i64, +) -> Result, String> { + let conn = db.get().map_err(|e| e.to_string())?; + db::get_failed_tagging_images(&conn, folder_id) + .map(|rows| { + rows.into_iter() + .map(|(image_id, filename, path, error)| FailedImageItem { + image_id, + filename, + path, + error, + }) + .collect() + }) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn semantic_search_images( db: State<'_, DbState>, diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 0f6a9e8..ccb8884 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -324,7 +324,10 @@ pub fn migrate(conn: &Connection) -> Result<()> { conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?; ensure_column(conn, "folders", "scan_error", "TEXT")?; ensure_column(conn, "folders", "sort_order", "INTEGER NOT NULL DEFAULT 0")?; - conn.execute("UPDATE folders SET sort_order = id WHERE sort_order = 0", [])?; + conn.execute( + "UPDATE folders SET sort_order = id WHERE sort_order = 0", + [], + )?; vector::migrate(conn)?; Ok(()) @@ -1629,6 +1632,7 @@ pub fn get_images( favorites_only: bool, rating_min: i64, embedding_failed_only: bool, + tagging_failed_only: bool, sort: &str, offset: i64, limit: i64, @@ -1652,6 +1656,7 @@ pub fn get_images( let search_pattern = search.map(|value| format!("%{value}%")); let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); + let tagging_failed_flag = i64::from(tagging_failed_only); let sql = format!( "SELECT id, folder_id, path, filename, thumbnail_path, width, height, file_size, created_at, modified_at, taken_at, mime_type, media_kind, duration_ms, video_codec, audio_codec, metadata_updated_at, metadata_error, @@ -1665,8 +1670,9 @@ pub fn get_images( AND (?4 = 0 OR favorite = 1) AND rating >= ?5 AND (?6 = 0 OR embedding_status = 'failed') + AND (?7 = 0 OR ai_tagger_error IS NOT NULL) ORDER BY {order} - LIMIT ?7 OFFSET ?8" + LIMIT ?8 OFFSET ?9" ); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map( @@ -1677,6 +1683,7 @@ pub fn get_images( favorites_flag, rating_min, embedding_failed_flag, + tagging_failed_flag, limit, offset ], @@ -1693,11 +1700,13 @@ pub fn count_images( favorites_only: bool, rating_min: i64, embedding_failed_only: bool, + tagging_failed_only: bool, ) -> Result { let search_pattern = search.map(|value| format!("%{value}%")); let favorites_flag = i64::from(favorites_only); let embedding_failed_flag = i64::from(embedding_failed_only); + let tagging_failed_flag = i64::from(tagging_failed_only); let count = conn.query_row( "SELECT COUNT(*) FROM images WHERE (?1 IS NULL OR folder_id = ?1) @@ -1705,14 +1714,16 @@ pub fn count_images( AND (?3 IS NULL OR media_kind = ?3) AND (?4 = 0 OR favorite = 1) AND rating >= ?5 - AND (?6 = 0 OR embedding_status = 'failed')", + AND (?6 = 0 OR embedding_status = 'failed') + AND (?7 = 0 OR ai_tagger_error IS NOT NULL)", params![ folder_id, search_pattern, media_kind, favorites_flag, rating_min, - embedding_failed_flag + embedding_failed_flag, + tagging_failed_flag ], |row| row.get(0), )?; @@ -1947,6 +1958,30 @@ pub fn get_failed_embedding_images( Ok(rows.collect::>>()?) } +type FailedTaggingRow = (i64, String, String, Option); + +pub fn get_failed_tagging_images( + conn: &Connection, + folder_id: i64, +) -> Result> { + let mut stmt = conn.prepare( + "SELECT i.id, i.filename, i.path, COALESCE(j.last_error, i.ai_tagger_error) + FROM images i + LEFT JOIN tagging_jobs j ON j.image_id = i.id AND j.status = 'failed' + WHERE i.folder_id = ?1 AND i.ai_tagger_error IS NOT NULL + ORDER BY i.filename", + )?; + let rows = stmt.query_map([folder_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })?; + Ok(rows.collect::>>()?) +} + pub fn update_generated_caption( conn: &Connection, image_id: i64, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8cddc55..79a5379 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -162,6 +162,7 @@ pub fn run() { commands::get_explore_tags, commands::get_images_by_ids, commands::get_failed_embedding_images, + commands::get_failed_tagging_images, commands::get_tagger_model_status, commands::get_tagger_acceleration, commands::set_tagger_acceleration, diff --git a/src/components/BackgroundTasks.tsx b/src/components/BackgroundTasks.tsx index b455572..39282ec 100644 --- a/src/components/BackgroundTasks.tsx +++ b/src/components/BackgroundTasks.tsx @@ -31,24 +31,52 @@ interface Task { snapshot: string; } -interface FailedEmbeddingItem { +interface FailedWorkerItem { image_id: number; filename: string; path: string; error: string | null; } +function FailedWorkerItemRow({ item }: { item: FailedWorkerItem }) { + return ( +
+ + + +
+

{item.filename}

+ {item.error && ( +

{item.error}

+ )} +
+ +
+ ); +} + export function BackgroundTasks() { const folders = useGalleryStore((state) => state.folders); const indexingProgress = useGalleryStore((state) => state.indexingProgress); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const retryFailedEmbeddings = useGalleryStore((state) => state.retryFailedEmbeddings); const queueTaggingJobs = useGalleryStore((state) => state.queueTaggingJobs); + const showFailedTagging = useGalleryStore((state) => state.showFailedTagging); const duplicateScanning = useGalleryStore((state) => state.duplicateScanning); const duplicateScanProgress = useGalleryStore((state) => state.duplicateScanProgress); const [expanded, setExpanded] = useState(false); const [dismissed, setDismissed] = useState>({}); - const [failedItems, setFailedItems] = useState>({}); + const [failedEmbeddingItems, setFailedEmbeddingItems] = useState>({}); + const [failedTaggingItems, setFailedTaggingItems] = useState>({}); const workerPaused = useGalleryStore((state) => state.workerPaused); const loadWorkerStates = useGalleryStore((state) => state.loadWorkerStates); @@ -58,27 +86,43 @@ export function BackgroundTasks() { void loadWorkerStates(); }, [folders, loadWorkerStates]); - // Fetch failed embedding filenames whenever the expanded panel opens or failure counts change. - const failedCounts = useMemo( + // Fetch failed filenames whenever the expanded panel opens or failure counts change. + const failedEmbeddingCounts = useMemo( () => Object.fromEntries( Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.embedding_failed ?? 0]), ), [mediaJobProgress], ); + const failedTaggingCounts = useMemo( + () => + Object.fromEntries( + Object.entries(mediaJobProgress).map(([id, p]) => [id, p?.tagging_failed ?? 0]), + ), + [mediaJobProgress], + ); useEffect(() => { if (!expanded) return; - for (const [folderId, count] of Object.entries(failedCounts)) { + for (const [folderId, count] of Object.entries(failedEmbeddingCounts)) { if (count > 0) { - invoke("get_failed_embedding_images", { + invoke("get_failed_embedding_images", { folderId: Number(folderId), }) - .then((items) => setFailedItems((prev) => ({ ...prev, [folderId]: items }))) + .then((items) => setFailedEmbeddingItems((prev) => ({ ...prev, [folderId]: items }))) .catch(() => undefined); } } - }, [expanded, failedCounts]); + for (const [folderId, count] of Object.entries(failedTaggingCounts)) { + if (count > 0) { + invoke("get_failed_tagging_images", { + folderId: Number(folderId), + }) + .then((items) => setFailedTaggingItems((prev) => ({ ...prev, [folderId]: items }))) + .catch(() => undefined); + } + } + }, [expanded, failedEmbeddingCounts, failedTaggingCounts]); const isWorkerPaused = (folderId: number, worker: WorkerKey) => { return workerPaused[folderId]?.[worker] ?? false; @@ -304,14 +348,14 @@ export function BackgroundTasks() { key={stage.label} className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ stage.failed - ? "bg-amber-500/10 text-amber-400" + ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700" : isPaused ? "bg-white/4 text-gray-600" : "bg-white/5 text-gray-400" }`} > {stage.label} - + {stage.detail} {workerKey && ( @@ -357,14 +401,27 @@ export function BackgroundTasks() { )} - {/* Retry (failed embeddings only) */} - {primary.hasFailedEmbeddings && primary.pendingMediaWork === 0 && ( - + {primary.pendingMediaWork === 0 && (primary.hasFailedEmbeddings || primary.hasFailedTagging) && ( +
+ {primary.hasFailedTagging ? ( + + ) : null} + +
)} {/* Expand chevron (only when multiple tasks) */} @@ -416,7 +473,7 @@ export function BackgroundTasks() { key={stage.label} className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] shrink-0 ${ stage.failed - ? "bg-amber-500/10 text-amber-400" + ? "bg-amber-500/10 text-amber-400 light-theme:border light-theme:border-amber-500/40 light-theme:bg-amber-100 light-theme:text-amber-700" : isPaused ? "bg-white/4 text-gray-600" : "bg-white/5 text-gray-500" @@ -428,7 +485,7 @@ export function BackgroundTasks() { )} {stage.label} - + {stage.detail} {workerKey && ( @@ -467,15 +524,25 @@ export function BackgroundTasks() { {taskHasFailed && ( - +
+ {task.hasFailedTagging ? ( + + ) : null} + +
)} {task.id >= 0 && ( @@ -497,31 +564,18 @@ export function BackgroundTasks() {

)} - {/* Failed embedding file list */} - {taskHasFailed && failedItems[task.id] && failedItems[task.id].length > 0 && ( + {/* Failed worker file lists */} + {taskHasFailed && failedEmbeddingItems[task.id] && failedEmbeddingItems[task.id].length > 0 && (
- {failedItems[task.id].map((item) => ( -
- - - -
-

{item.filename}

- {item.error && ( -

{item.error}

- )} -
- -
+ {failedEmbeddingItems[task.id].map((item) => ( + + ))} +
+ )} + {taskHasFailed && failedTaggingItems[task.id] && failedTaggingItems[task.id].length > 0 && ( +
+ {failedTaggingItems[task.id].map((item) => ( + ))}
)} diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index a08499a..f82f2af 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -88,7 +88,7 @@ export function TitleBar() { aria-label={`Update available — click to update to Phokus v${updateVersion}`} className="relative flex h-5 w-5 items-center justify-center rounded-md bg-white/8 overflow-hidden text-gray-300 transition-colors hover:bg-white/12" > - + {/* Custom tooltip — fades in ~100ms instead of the native ~500ms delay. */} diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 573da34..56e021e 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -158,6 +158,8 @@ export function Toolbar() { const setMinimumRating = useGalleryStore((state) => state.setMinimumRating); const failedEmbeddingsOnly = useGalleryStore((state) => state.failedEmbeddingsOnly); const setFailedEmbeddingsOnly = useGalleryStore((state) => state.setFailedEmbeddingsOnly); + const failedTaggingOnly = useGalleryStore((state) => state.failedTaggingOnly); + const setFailedTaggingOnly = useGalleryStore((state) => state.setFailedTaggingOnly); const similarScope = useGalleryStore((state) => state.similarScope); const setSimilarScope = useGalleryStore((state) => state.setSimilarScope); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); @@ -166,6 +168,7 @@ export function Toolbar() { const activeView = useGalleryStore((state) => state.activeView); const hasAnyFailedEmbeddings = Object.values(mediaJobProgress).some((p) => p.embedding_failed > 0); + const hasAnyFailedTagging = Object.values(mediaJobProgress).some((p) => p.tagging_failed > 0); const [searchCommand, setSearchCommand] = useState(null); const [searchQuery, setSearchQuery] = useState(search); @@ -441,12 +444,12 @@ export function Toolbar() { {/* Filter row */}
- { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); }} /> - { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> - { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); }} /> - { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); }} /> - { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); }} /> - { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); }} /> + { setMediaFilter("all"); setFavoritesOnly(false); setMinimumRating(0); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMediaFilter("image"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMediaFilter("video"); setFavoritesOnly(false); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setFavoritesOnly(!favoritesOnly); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMinimumRating(minimumRating === 1 ? 0 : 1); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> + { setMinimumRating(minimumRating === 4 ? 0 : 4); setFailedEmbeddingsOnly(false); setFailedTaggingOnly(false); }} /> setSimilarScope("current_folder")} /> setSimilarScope("all_media")} /> {hasAnyFailedEmbeddings ? ( @@ -457,6 +460,14 @@ export function Toolbar() { onClick={() => setFailedEmbeddingsOnly(!failedEmbeddingsOnly)} /> ) : null} + {hasAnyFailedTagging ? ( + setFailedTaggingOnly(!failedTaggingOnly)} + /> + ) : null} {isSimilarResults ? Current similar scope: {similarScope === "current_folder" ? "current folder" : "all media"} : null}
diff --git a/src/store.ts b/src/store.ts index 4d79b4a..05cbe35 100644 --- a/src/store.ts +++ b/src/store.ts @@ -309,6 +309,7 @@ interface GalleryState { favoritesOnly: boolean; minimumRating: number; failedEmbeddingsOnly: boolean; + failedTaggingOnly: boolean; zoomPreset: ZoomPreset; selectedImage: ImageRecord | null; collectionTitle: string | null; @@ -403,6 +404,8 @@ interface GalleryState { setFavoritesOnly: (favoritesOnly: boolean) => void; setMinimumRating: (minimumRating: number) => void; setFailedEmbeddingsOnly: (failedEmbeddingsOnly: boolean) => void; + setFailedTaggingOnly: (failedTaggingOnly: boolean) => void; + showFailedTagging: (folderId: number) => void; setZoomPreset: (zoomPreset: ZoomPreset) => void; openImage: (image: ImageRecord) => void; closeImage: () => void; @@ -593,6 +596,7 @@ function matchesFilters( favoritesOnly: boolean, minimumRating: number, failedEmbeddingsOnly: boolean, + failedTaggingOnly: boolean, search: string, ): boolean { const matchesFolder = selectedFolderId === null || image.folder_id === selectedFolderId; @@ -600,7 +604,8 @@ function matchesFilters( const matchesFavorite = !favoritesOnly || image.favorite; const matchesRating = image.rating >= minimumRating; const matchesFailedEmbedding = !failedEmbeddingsOnly || image.embedding_status === "failed"; - return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesSearch(image, search); + const matchesFailedTagging = !failedTaggingOnly || image.ai_tagger_error !== null; + return matchesFolder && matchesMedia && matchesFavorite && matchesRating && matchesFailedEmbedding && matchesFailedTagging && matchesSearch(image, search); } function compareNullableNumber(a: number | null, b: number | null): number { @@ -710,6 +715,7 @@ export const useGalleryStore = create((set, get) => ({ favoritesOnly: false, minimumRating: 0, failedEmbeddingsOnly: false, + failedTaggingOnly: false, zoomPreset: "comfortable", selectedImage: null, collectionTitle: null, @@ -879,7 +885,7 @@ export const useGalleryStore = create((set, get) => ({ }, selectFolder: (folderId) => { - set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, imageLoadError: null }); + set({ selectedFolderId: folderId, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, activeView: "gallery", failedEmbeddingsOnly: false, failedTaggingOnly: false, imageLoadError: null }); void get().loadImages(true); }, @@ -912,7 +918,7 @@ export const useGalleryStore = create((set, get) => ({ }, loadImages: async (reset = false) => { - const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, activeView } = get(); + const { selectedFolderId, search, sort, loadedCount, mediaFilter, favoritesOnly, minimumRating, failedEmbeddingsOnly, failedTaggingOnly, activeView } = get(); const parsedSearch = parseSearchValue(search); const requestToken = ++galleryRequestToken; set({ loadingImages: true, imageLoadError: null }); @@ -999,6 +1005,7 @@ export const useGalleryStore = create((set, get) => ({ favorites_only: favoritesOnly, rating_min: minimumRating > 0 ? minimumRating : null, embedding_failed_only: failedEmbeddingsOnly, + tagging_failed_only: failedTaggingOnly, sort, offset, limit: activeView === "timeline" ? TIMELINE_PAGE_SIZE : PAGE_SIZE, @@ -1108,7 +1115,35 @@ export const useGalleryStore = create((set, get) => ({ }, setFailedEmbeddingsOnly: (failedEmbeddingsOnly) => { - set({ failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + set({ failedEmbeddingsOnly, failedTaggingOnly: failedEmbeddingsOnly ? false : get().failedTaggingOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + setFailedTaggingOnly: (failedTaggingOnly) => { + set({ failedTaggingOnly, failedEmbeddingsOnly: failedTaggingOnly ? false : get().failedEmbeddingsOnly, images: [], loadedCount: 0, collectionTitle: null, similarSourceImageId: null, similarHasMore: false, imageLoadError: null }); + void get().loadImages(true); + }, + + showFailedTagging: (folderId) => { + set({ + selectedFolderId: folderId, + activeView: "gallery", + search: "", + mediaFilter: "all", + favoritesOnly: false, + minimumRating: 0, + failedEmbeddingsOnly: false, + failedTaggingOnly: true, + images: [], + loadedCount: 0, + collectionTitle: null, + similarSourceImageId: null, + similarSourceFolderId: null, + similarHasMore: false, + similarFolderId: null, + similarCrop: null, + imageLoadError: null, + }); void get().loadImages(true); }, @@ -2205,6 +2240,7 @@ export const useGalleryStore = create((set, get) => ({ state.favoritesOnly, state.minimumRating, state.failedEmbeddingsOnly, + state.failedTaggingOnly, state.search, ), ); @@ -2244,6 +2280,7 @@ export const useGalleryStore = create((set, get) => ({ state.favoritesOnly, state.minimumRating, state.failedEmbeddingsOnly, + state.failedTaggingOnly, state.search, ), );