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.
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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":
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user