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:
2026-07-01 10:31:22 +01:00
parent d5b93b2e21
commit 4cdbc54d18
9 changed files with 505 additions and 19 deletions
+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)