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,
),
);