Compare commits

..

3 Commits

Author SHA1 Message Date
LyAhn 29d9106039 Merge AI tag maintenance tools
github/actions/ci GitHub Actions CI finished: failure
Add an AI tag reset flow across Settings and Explore so AI-generated tags, AI tagging metadata, and stale tagging jobs can be cleared without affecting user tags or other media data.

Also add subtle AI source indicators in the tag manager and an Extreme UI Lab scenario for stress-testing large tag/library counts.
2026-07-01 11:38:51 +01:00
LyAhn a78111c8d4 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.
2026-07-01 10:31:59 +01:00
LyAhn 4cdbc54d18 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.
2026-07-01 10:31:22 +01:00
10 changed files with 566 additions and 38 deletions
+42
View File
@@ -2179,6 +2179,12 @@ pub struct ClearTaggingJobsParams {
pub folder_ids: Option<Vec<i64>>,
}
#[derive(Deserialize)]
pub struct ResetAiTagsParams {
pub folder_id: Option<i64>,
pub folder_ids: Option<Vec<i64>>,
}
#[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<usize, String> {
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<i64>) =
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>,
+116 -2
View File
@@ -146,6 +146,8 @@ pub struct ExploreTagEntry {
pub count: i64,
pub representative_image_id: i64,
pub thumbnail_path: Option<String>,
pub has_ai_source: bool,
pub has_user_source: bool,
}
#[derive(Debug, Clone)]
@@ -2341,7 +2343,9 @@ pub fn search_tags_autocomplete(
) -> Result<Vec<ExploreTagEntry>> {
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::<rusqlite::Result<Vec<_>>>()?;
@@ -2440,7 +2446,9 @@ pub fn get_explore_tags(
limit: usize,
) -> Result<Vec<ExploreTagEntry>> {
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::<rusqlite::Result<Vec<_>>>()?;
@@ -2848,6 +2858,110 @@ pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
Ok(removed)
}
pub fn reset_ai_tags(conn: &Connection, folder_id: Option<i64>) -> Result<usize> {
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::<rusqlite::Result<Vec<_>>>()?
}
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::<rusqlite::Result<Vec<_>>>()?
}
};
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<usize> {
let inserted = conn.execute(
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
+1
View File
@@ -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,
+145 -9
View File
@@ -678,6 +678,51 @@ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
{ value: "za", label: "Z-A" },
];
function AiSourceGlyph({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2.75l.82 2.42a2.1 2.1 0 0 0 1.32 1.32l2.41.81-2.41.82a2.1 2.1 0 0 0-1.32 1.32L8 11.85l-.82-2.41a2.1 2.1 0 0 0-1.32-1.32L3.45 7.3l2.41-.81a2.1 2.1 0 0 0 1.32-1.32L8 2.75Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path d="M12.25 11.15l.25.74.75.26-.75.25-.25.75-.26-.75-.74-.25.74-.26.26-.74Z" fill="currentColor" />
</svg>
);
}
function RenameGlyph({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3.25 11.85l.45-2.14 5.95-5.95a1.33 1.33 0 0 1 1.88 0l.71.71a1.33 1.33 0 0 1 0 1.88L6.29 12.3l-2.14.45a.76.76 0 0 1-.9-.9Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path d="M8.75 4.65l2.6 2.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
</svg>
);
}
function DeleteGlyph({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.25 4.65h9.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
<path d="M6.35 4.55V3.7c0-.55.45-1 1-1h1.3c.55 0 1 .45 1 1v.85" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
<path
d="M5.1 6.2l.35 5.65c.04.63.56 1.12 1.19 1.12h2.72c.63 0 1.15-.49 1.19-1.12l.35-5.65"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
</svg>
);
}
// 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 (
<div
data-tag-manager-tile
className={`tag-manager-tile group relative min-w-0 rounded-lg border border-white/[0.06] bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:border-white/[0.14] focus-within:bg-white/[0.045] hover:border-white/[0.12] hover:bg-white/[0.04] ${
editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
}`}
className={`tag-manager-tile ${entry.has_ai_source ? "tag-manager-tile-ai" : ""} group relative min-w-0 rounded-lg border bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:bg-white/[0.045] hover:bg-white/[0.04] ${
entry.has_ai_source
? "border-white/[0.08] shadow-[inset_0_1px_0_rgba(125,211,252,0.055)] focus-within:border-white/[0.15] hover:border-white/[0.13]"
: "border-white/[0.06] focus-within:border-white/[0.14] hover:border-white/[0.12]"
} ${editing || confirming ? "min-h-[82px]" : "min-h-[46px]"}`}
>
<div className="flex min-w-0 items-start gap-2">
{editing ? (
@@ -749,9 +802,20 @@ function TagManageTile({
</button>
</Tooltip>
)}
<span className="tag-manager-count absolute right-2.5 top-2 shrink-0 rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
<div className="absolute right-2.5 top-2 shrink-0">
{entry.has_ai_source ? (
<Tooltip label={sourceLabel} delay={400} anchorToCursor>
<span className="tag-manager-count tag-manager-count-ai inline-flex h-5 items-center gap-1 rounded-full border border-sky-300/10 bg-sky-300/[0.075] px-1.5 text-[10px] tabular-nums text-sky-200/75">
<AiSourceGlyph className="tag-manager-ai-glyph h-3 w-3" />
{entry.count.toLocaleString()}
</span>
</Tooltip>
) : (
<span className="tag-manager-count rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
{entry.count.toLocaleString()}
</span>
)}
</div>
</div>
{editing ? (
@@ -789,21 +853,25 @@ function TagManageTile({
</button>
</div>
) : (
<div className="pointer-events-none absolute right-12 top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<div className="pointer-events-none absolute right-[5rem] top-1/2 flex -translate-y-1/2 items-center gap-1 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
<button
className="tag-manager-action rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
className="tag-manager-action inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-white/8 hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/80"
onClick={() => setEditing(true)}
aria-label={`Rename ${entry.tag}`}
>
Rename
<RenameGlyph className="h-3.5 w-3.5" />
</button>
</Tooltip>
<Tooltip label="Delete this tag in the current scope" delay={400} anchorToCursor>
<button
className="tag-manager-action tag-manager-action-danger rounded-md border border-white/10 bg-gray-950/80 px-2 py-1 text-[11px] text-white/60 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
className="tag-manager-action tag-manager-action-danger inline-flex h-6 w-6 items-center justify-center rounded-md border border-white/10 bg-gray-950/80 text-white/55 transition-colors hover:bg-red-500/10 hover:text-red-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400/80"
onClick={() => setConfirming(true)}
aria-label={`Delete ${entry.tag}`}
>
Delete
<DeleteGlyph className="h-3.5 w-3.5" />
</button>
</Tooltip>
</div>
)}
</div>
@@ -815,17 +883,24 @@ function TagManageList({
onSearch,
onRename,
onDelete,
onResetAiTags,
scopeLabel,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
onResetAiTags: () => Promise<number>;
scopeLabel: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<TagManageSort>("count_desc");
const [columns, setColumns] = useState(3);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetStatus, setResetStatus] = useState<string | null>(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 (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
@@ -887,9 +986,33 @@ function TagManageList({
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
</p>
{resetStatus ? <p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p> : null}
</div>
<div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<button
className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
resetConfirming
? "tag-manager-reset-button-confirm border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25"
: "border-white/8 bg-black/20 text-white/50 hover:bg-white/[0.06] hover:text-white/80"
}`}
onClick={() => void runResetAiTags()}
disabled={resetting}
>
{resetting ? "Resetting..." : resetConfirming ? "Confirm reset" : "Reset AI tags"}
</button>
{resetConfirming ? (
<button
className="tag-manager-reset-cancel h-9 rounded-lg border border-white/8 bg-black/20 px-3 text-xs text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/75 disabled:opacity-50"
onClick={() => {
setResetConfirming(false);
setResetStatus(null);
}}
disabled={resetting}
>
Cancel
</button>
) : null}
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<input
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
@@ -983,11 +1106,22 @@ export function ExploreView() {
const searchForTag = useGalleryStore((state) => 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}
/>
</div>
) : (
+69 -2
View File
@@ -166,6 +166,8 @@ export function SettingsModal() {
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(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<string | null>(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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div
@@ -673,17 +717,40 @@ export function SettingsModal() {
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => 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"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
<button
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
taggerResetConfirming
? "border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25 light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
}`}
onClick={runResetAiTags}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerResetting ? "Resetting..." : taggerResetConfirming ? "Confirm reset" : "Reset AI tags"}
</button>
{taggerResetConfirming ? (
<button
className="rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={() => {
setTaggerResetConfirming(false);
setTaggerQueueStatus(null);
}}
disabled={taggerResetting}
>
Cancel
</button>
) : null}
</div>
</SettingsItem>
+64 -2
View File
@@ -106,6 +106,38 @@ function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] {
.slice(0, limit);
}
function rebuildExploreTags(): ExploreTagEntry[] {
const entries = new Map<
string,
{ imageIds: Set<number>; 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<number>(), 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();
@@ -174,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();
@@ -285,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<string, number>();
for (const imageTags of Object.values(db.tagsByImageId)) {
@@ -400,6 +436,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":
+56 -16
View File
@@ -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<number, ImageTag[]> {
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,12 +308,23 @@ 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,
has_user_source: sourceCycle !== 0,
};
}).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag));
}
+2 -1
View File
@@ -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<MockScenario>([
"rich",
@@ -8,6 +8,7 @@ const SCENARIOS = new Set<MockScenario>([
"album",
"errors",
"huge",
"extreme",
]);
export function getMockScenario(): MockScenario {
+41
View File
@@ -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;
+24
View File
@@ -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<number>;
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
resetAiTags: (folderId?: number | null) => Promise<number>;
resetAiTagsForFolders: (folderIds: number[]) => Promise<number>;
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
scanDuplicates: (folderId?: number | null) => Promise<void>;
toggleDuplicateSelected: (imageId: number) => void;
@@ -2383,6 +2387,26 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return cleared;
},
resetAiTags: async (folderId = get().selectedFolderId) => {
const reset = await invoke<number>("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<number>("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<ImageTag[]>("get_image_tags", {
params: { image_id: imageId },