Compare commits
3 Commits
d5b93b2e21
...
29d9106039
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d9106039 | |||
| a78111c8d4 | |||
| 4cdbc54d18 |
@@ -2179,6 +2179,12 @@ pub struct ClearTaggingJobsParams {
|
|||||||
pub folder_ids: Option<Vec<i64>>,
|
pub folder_ids: Option<Vec<i64>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ResetAiTagsParams {
|
||||||
|
pub folder_id: Option<i64>,
|
||||||
|
pub folder_ids: Option<Vec<i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct GetImageTagsParams {
|
pub struct GetImageTagsParams {
|
||||||
pub image_id: i64,
|
pub image_id: i64,
|
||||||
@@ -2377,6 +2383,42 @@ pub async fn clear_tagging_jobs(
|
|||||||
Ok(n)
|
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]
|
#[tauri::command]
|
||||||
pub async fn get_image_tags(
|
pub async fn get_image_tags(
|
||||||
db: State<'_, DbState>,
|
db: State<'_, DbState>,
|
||||||
|
|||||||
+116
-2
@@ -146,6 +146,8 @@ pub struct ExploreTagEntry {
|
|||||||
pub count: i64,
|
pub count: i64,
|
||||||
pub representative_image_id: i64,
|
pub representative_image_id: i64,
|
||||||
pub thumbnail_path: Option<String>,
|
pub thumbnail_path: Option<String>,
|
||||||
|
pub has_ai_source: bool,
|
||||||
|
pub has_user_source: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -2341,7 +2343,9 @@ pub fn search_tags_autocomplete(
|
|||||||
) -> Result<Vec<ExploreTagEntry>> {
|
) -> Result<Vec<ExploreTagEntry>> {
|
||||||
let pattern = format!("%{}%", query.to_lowercase());
|
let pattern = format!("%{}%", query.to_lowercase());
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM image_tags t
|
||||||
JOIN images i ON i.id = t.image_id
|
JOIN images i ON i.id = t.image_id
|
||||||
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
||||||
@@ -2357,6 +2361,8 @@ pub fn search_tags_autocomplete(
|
|||||||
count: row.get(1)?,
|
count: row.get(1)?,
|
||||||
representative_image_id: row.get(2)?,
|
representative_image_id: row.get(2)?,
|
||||||
thumbnail_path: None, // skip per-suggestion thumbnail for speed
|
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<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
@@ -2440,7 +2446,9 @@ pub fn get_explore_tags(
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<Vec<ExploreTagEntry>> {
|
) -> Result<Vec<ExploreTagEntry>> {
|
||||||
let mut stmt = conn.prepare(
|
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
|
FROM image_tags t
|
||||||
JOIN images i ON i.id = t.image_id
|
JOIN images i ON i.id = t.image_id
|
||||||
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
WHERE (?1 IS NULL OR i.folder_id = ?1)
|
||||||
@@ -2462,6 +2470,8 @@ pub fn get_explore_tags(
|
|||||||
count: row.get(1)?,
|
count: row.get(1)?,
|
||||||
representative_image_id,
|
representative_image_id,
|
||||||
thumbnail_path,
|
thumbnail_path,
|
||||||
|
has_ai_source: row.get::<_, i64>(3)? != 0,
|
||||||
|
has_user_source: row.get::<_, i64>(4)? != 0,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
@@ -2848,6 +2858,110 @@ pub fn delete_tag(conn: &Connection, name: &str) -> Result<i64> {
|
|||||||
Ok(removed)
|
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> {
|
pub fn enqueue_missing_tagging_jobs_for_folder(conn: &Connection, folder_id: i64) -> Result<usize> {
|
||||||
let inserted = conn.execute(
|
let inserted = conn.execute(
|
||||||
"INSERT INTO tagging_jobs (image_id, status, attempts, last_error, created_at, updated_at)
|
"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::delete_tagger_model,
|
||||||
commands::queue_tagging_jobs,
|
commands::queue_tagging_jobs,
|
||||||
commands::clear_tagging_jobs,
|
commands::clear_tagging_jobs,
|
||||||
|
commands::reset_ai_tags,
|
||||||
commands::get_image_tags,
|
commands::get_image_tags,
|
||||||
commands::add_user_tag,
|
commands::add_user_tag,
|
||||||
commands::remove_tag,
|
commands::remove_tag,
|
||||||
|
|||||||
+151
-15
@@ -678,6 +678,51 @@ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
|
|||||||
{ value: "za", label: "Z-A" },
|
{ 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
|
// 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.
|
// name already exists, and delete applies across the scoped tag set.
|
||||||
function TagManageTile({
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tag-manager-tile
|
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] ${
|
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] ${
|
||||||
editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
|
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">
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -749,9 +802,20 @@ function TagManageTile({
|
|||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</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.count.toLocaleString()}
|
{entry.has_ai_source ? (
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -789,21 +853,25 @@ function TagManageTile({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<Tooltip label="Rename or merge into another tag" delay={400} anchorToCursor>
|
||||||
<button
|
<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)}
|
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 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}`}
|
||||||
|
>
|
||||||
|
<DeleteGlyph className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<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"
|
|
||||||
onClick={() => setConfirming(true)}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -815,17 +883,24 @@ function TagManageList({
|
|||||||
onSearch,
|
onSearch,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onResetAiTags,
|
||||||
|
scopeLabel,
|
||||||
}: {
|
}: {
|
||||||
entries: ExploreTagEntry[];
|
entries: ExploreTagEntry[];
|
||||||
onSearch: (tag: string) => void;
|
onSearch: (tag: string) => void;
|
||||||
onRename: (from: string, to: string) => Promise<void>;
|
onRename: (from: string, to: string) => Promise<void>;
|
||||||
onDelete: (tag: string) => Promise<void>;
|
onDelete: (tag: string) => Promise<void>;
|
||||||
|
onResetAiTags: () => Promise<number>;
|
||||||
|
scopeLabel: string;
|
||||||
}) {
|
}) {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const measureRef = useRef<HTMLDivElement>(null);
|
const measureRef = useRef<HTMLDivElement>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<TagManageSort>("count_desc");
|
const [sort, setSort] = useState<TagManageSort>("count_desc");
|
||||||
const [columns, setColumns] = useState(3);
|
const [columns, setColumns] = useState(3);
|
||||||
|
const [resetConfirming, setResetConfirming] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [resetStatus, setResetStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = measureRef.current;
|
const el = measureRef.current;
|
||||||
@@ -870,6 +945,30 @@ function TagManageList({
|
|||||||
const visibleItems = rowVirtualizer.getVirtualItems();
|
const visibleItems = rowVirtualizer.getVirtualItems();
|
||||||
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]);
|
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 (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
<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">
|
<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">
|
<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.
|
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
|
||||||
</p>
|
</p>
|
||||||
|
{resetStatus ? <p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
|
<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">
|
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
|
||||||
<input
|
<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"
|
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 searchForTag = useGalleryStore((state) => state.searchForTag);
|
||||||
const renameTag = useGalleryStore((state) => state.renameTag);
|
const renameTag = useGalleryStore((state) => state.renameTag);
|
||||||
const deleteTag = useGalleryStore((state) => state.deleteTag);
|
const deleteTag = useGalleryStore((state) => state.deleteTag);
|
||||||
|
const resetAiTags = useGalleryStore((state) => state.resetAiTags);
|
||||||
|
const folders = useGalleryStore((state) => state.folders);
|
||||||
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
|
||||||
// Manage mode lives in the store so it can be opened from elsewhere (Settings).
|
// Manage mode lives in the store so it can be opened from elsewhere (Settings).
|
||||||
const manageTags = useGalleryStore((state) => state.tagManagerOpen);
|
const manageTags = useGalleryStore((state) => state.tagManagerOpen);
|
||||||
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen);
|
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen);
|
||||||
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
|
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(() => {
|
useEffect(() => {
|
||||||
if (exploreMode === "visual") void loadVisualClusters();
|
if (exploreMode === "visual") void loadVisualClusters();
|
||||||
@@ -1080,6 +1214,8 @@ export function ExploreView() {
|
|||||||
onSearch={searchForTag}
|
onSearch={searchForTag}
|
||||||
onRename={renameTag}
|
onRename={renameTag}
|
||||||
onDelete={handleDeleteTag}
|
onDelete={handleDeleteTag}
|
||||||
|
onResetAiTags={handleResetAiTags}
|
||||||
|
scopeLabel={tagManagerScopeLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ export function SettingsModal() {
|
|||||||
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
|
||||||
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
const [taggerQueueing, setTaggerQueueing] = useState(false);
|
||||||
const [taggerClearing, setTaggerClearing] = useState(false);
|
const [taggerClearing, setTaggerClearing] = useState(false);
|
||||||
|
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false);
|
||||||
|
const [taggerResetting, setTaggerResetting] = useState(false);
|
||||||
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
|
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
|
||||||
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
|
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
|
||||||
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
|
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
|
||||||
@@ -226,6 +228,8 @@ export function SettingsModal() {
|
|||||||
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
|
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
|
||||||
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
|
||||||
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
|
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 openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
|
||||||
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
|
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
|
||||||
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
|
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
|
||||||
@@ -341,6 +345,7 @@ export function SettingsModal() {
|
|||||||
setTaggerClearing(true);
|
setTaggerClearing(true);
|
||||||
}
|
}
|
||||||
setTaggerQueueStatus(null);
|
setTaggerQueueStatus(null);
|
||||||
|
setTaggerResetConfirming(false);
|
||||||
|
|
||||||
void perform
|
void perform
|
||||||
.then((count) => {
|
.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 (
|
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 className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
|
||||||
<div
|
<div
|
||||||
@@ -673,17 +717,40 @@ export function SettingsModal() {
|
|||||||
<button
|
<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"
|
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")}
|
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"}
|
{taggerQueueing ? "Queueing..." : "Queue tagging"}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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")}
|
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"}
|
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
|
|||||||
+64
-2
@@ -106,6 +106,38 @@ function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] {
|
|||||||
.slice(0, limit);
|
.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[] {
|
function semanticSearch(payload: unknown): ImageRecord[] {
|
||||||
const p = params(payload);
|
const p = params(payload);
|
||||||
const query = String(p.query ?? "").toLowerCase();
|
const query = String(p.query ?? "").toLowerCase();
|
||||||
@@ -174,7 +206,7 @@ function updateImages(ids: number[], updates: { favorite?: boolean | null; ratin
|
|||||||
function refreshAlbumCounts() {
|
function refreshAlbumCounts() {
|
||||||
for (const album of db.albums) {
|
for (const album of db.albums) {
|
||||||
const ids = db.albumImageIds[album.id] ?? [];
|
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_image_id = ids[0] ?? null;
|
||||||
album.cover_thumbnail_path = db.images.find((image) => image.id === ids[0])?.thumbnail_path ?? null;
|
album.cover_thumbnail_path = db.images.find((image) => image.id === ids[0])?.thumbnail_path ?? null;
|
||||||
album.updated_at = new Date().toISOString();
|
album.updated_at = new Date().toISOString();
|
||||||
@@ -285,7 +317,11 @@ export async function handleMockCommand(cmd: string, payload?: unknown): Promise
|
|||||||
case "get_visual_clusters":
|
case "get_visual_clusters":
|
||||||
return db.scenario === "empty" ? [] : db.visualClusters;
|
return db.scenario === "empty" ? [] : db.visualClusters;
|
||||||
case "get_explore_tags":
|
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": {
|
case "get_related_tags": {
|
||||||
const relatedCounts = new Map<string, number>();
|
const relatedCounts = new Map<string, number>();
|
||||||
for (const imageTags of Object.values(db.tagsByImageId)) {
|
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 "queue_tagging_jobs":
|
||||||
case "clear_tagging_jobs":
|
case "clear_tagging_jobs":
|
||||||
return 12;
|
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":
|
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: [] };
|
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":
|
case "get_tagger_model":
|
||||||
|
|||||||
+56
-16
@@ -29,6 +29,17 @@ export interface MockDb {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const folderNames = ["Camera Roll", "Client Selects", "Video Archive"];
|
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 = [
|
const mediaNames = [
|
||||||
"alpine-lake",
|
"alpine-lake",
|
||||||
"city-after-rain",
|
"city-after-rain",
|
||||||
@@ -119,6 +130,16 @@ const hugeTags = [
|
|||||||
}),
|
}),
|
||||||
...Array.from({ length: 320 }, (_, index) => `fixture concept ${String(index + 1).padStart(3, "0")}`),
|
...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"];
|
const aiRatings: AiRating[] = ["general", "sensitive", "questionable"];
|
||||||
|
|
||||||
function daysAgo(days: number): string {
|
function daysAgo(days: number): string {
|
||||||
@@ -177,7 +198,8 @@ function makeImage(index: number, folderId: number, scenario: MockScenario): Ima
|
|||||||
|
|
||||||
function makeFolders(scenario: MockScenario): Folder[] {
|
function makeFolders(scenario: MockScenario): Folder[] {
|
||||||
if (scenario === "empty") return [];
|
if (scenario === "empty") return [];
|
||||||
return folderNames.map((name, index) => ({
|
const names = scenario === "extreme" ? extremeFolderNames : folderNames;
|
||||||
|
return names.map((name, index) => ({
|
||||||
id: index + 1,
|
id: index + 1,
|
||||||
path: `C:\\Users\\phokus\\Pictures\\${name.replace(/ /g, "")}`,
|
path: `C:\\Users\\phokus\\Pictures\\${name.replace(/ /g, "")}`,
|
||||||
name,
|
name,
|
||||||
@@ -190,21 +212,25 @@ function makeFolders(scenario: MockScenario): Folder[] {
|
|||||||
|
|
||||||
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
function makeImages(scenario: MockScenario, folders: Folder[]): ImageRecord[] {
|
||||||
if (scenario === "empty") return [];
|
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));
|
const images = Array.from({ length: count }, (_, index) => makeImage(index, folders[index % folders.length].id, scenario));
|
||||||
for (const folder of folders) {
|
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;
|
return images;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tagVocabularyForScenario(scenario: MockScenario): string[] {
|
function tagVocabularyForScenario(scenario: MockScenario): string[] {
|
||||||
|
if (scenario === "extreme") return extremeTags;
|
||||||
return scenario === "huge" ? hugeTags : tags;
|
return scenario === "huge" ? hugeTags : tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTags(images: ImageRecord[], scenario: MockScenario): Record<number, ImageTag[]> {
|
function makeTags(images: ImageRecord[], scenario: MockScenario): Record<number, ImageTag[]> {
|
||||||
const vocabulary = tagVocabularyForScenario(scenario);
|
const vocabulary = tagVocabularyForScenario(scenario);
|
||||||
const tagCount = scenario === "huge" ? 9 : 3;
|
const tagCount = scenario === "extreme" ? 14 : scenario === "huge" ? 9 : 3;
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
images.map((image, index) => [
|
images.map((image, index) => [
|
||||||
image.id,
|
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 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 favorites = images.filter((image) => image.favorite).slice(0, 40).map((image) => image.id);
|
||||||
const albumImageIds = { 1: selects, 2: motion, 3: favorites };
|
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[] = [
|
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: 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: motion.length, sort_order: 2, created_at: daysAgo(38), updated_at: daysAgo(3) },
|
{ 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: favorites.length, sort_order: 3, created_at: daysAgo(20), updated_at: daysAgo(1) },
|
{ 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 };
|
return { albums, albumImageIds };
|
||||||
}
|
}
|
||||||
@@ -238,15 +267,15 @@ function makeAlbums(images: ImageRecord[], scenario: MockScenario): { albums: Al
|
|||||||
function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgress[] {
|
function makeProgress(folders: Folder[], scenario: MockScenario): FolderJobProgress[] {
|
||||||
return folders.map((folder, index) => ({
|
return folders.map((folder, index) => ({
|
||||||
folder_id: folder.id,
|
folder_id: folder.id,
|
||||||
thumbnail_pending: scenario === "busy" ? 18 - index * 3 : 0,
|
thumbnail_pending: scenario === "extreme" ? 180 - index * 9 : scenario === "busy" ? 18 - index * 3 : 0,
|
||||||
metadata_pending: scenario === "busy" ? 12 - index * 2 : 0,
|
metadata_pending: scenario === "extreme" ? 140 - index * 7 : scenario === "busy" ? 12 - index * 2 : 0,
|
||||||
embedding_pending: scenario === "busy" ? 36 - index * 4 : scenario === "errors" ? 4 : 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_ready: Math.max(0, folder.image_count - (scenario === "busy" ? 20 : 2)),
|
||||||
embedding_failed: scenario === "errors" ? 3 + index : 0,
|
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_ready: Math.max(0, Math.floor(folder.image_count * 0.5)),
|
||||||
caption_failed: scenario === "errors" ? 2 : 0,
|
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_ready: Math.max(0, Math.floor(folder.image_count * 0.65)),
|
||||||
tagging_failed: scenario === "errors" ? 4 : 0,
|
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
|
// "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
|
// fixed handful, and size the counts off a large virtual library so the badges
|
||||||
// read like a big collection.
|
// read like a big collection.
|
||||||
const clusterCount = scenario === "huge" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20)));
|
const clusterCount = scenario === "huge" || scenario === "extreme" ? 30 : Math.min(30, Math.max(5, Math.round(images.length / 20)));
|
||||||
const virtualTotal = scenario === "huge" ? 12_000 : images.length;
|
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.
|
// 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 weights = Array.from({ length: clusterCount }, (_, i) => 1 / (i + 1.5));
|
||||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||||
@@ -279,12 +308,23 @@ function makeExploreTags(images: ImageRecord[], scenario: MockScenario): Explore
|
|||||||
const vocabulary = tagVocabularyForScenario(scenario);
|
const vocabulary = tagVocabularyForScenario(scenario);
|
||||||
return vocabulary.map((tag, index) => {
|
return vocabulary.map((tag, index) => {
|
||||||
const representative = images[index % Math.max(images.length, 1)];
|
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 {
|
return {
|
||||||
tag,
|
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,
|
representative_image_id: representative?.id ?? index + 1,
|
||||||
thumbnail_path: representative?.thumbnail_path ?? null,
|
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));
|
}).sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>([
|
const SCENARIOS = new Set<MockScenario>([
|
||||||
"rich",
|
"rich",
|
||||||
@@ -8,6 +8,7 @@ const SCENARIOS = new Set<MockScenario>([
|
|||||||
"album",
|
"album",
|
||||||
"errors",
|
"errors",
|
||||||
"huge",
|
"huge",
|
||||||
|
"extreme",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function getMockScenario(): MockScenario {
|
export function getMockScenario(): MockScenario {
|
||||||
|
|||||||
@@ -259,6 +259,33 @@ html[data-theme="subtle-light"] .tag-manager-filter:focus {
|
|||||||
border-color: #7aa2e3 !important;
|
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 {
|
html[data-theme="subtle-light"] .tag-manager-clear {
|
||||||
color: #6b645a !important;
|
color: #6b645a !important;
|
||||||
}
|
}
|
||||||
@@ -292,6 +319,20 @@ html[data-theme="subtle-light"] .tag-manager-count {
|
|||||||
color: #6b645a !important;
|
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 {
|
html[data-theme="subtle-light"] .tag-manager-edit-input {
|
||||||
background: #fffdfa !important;
|
background: #fffdfa !important;
|
||||||
color: #111827 !important;
|
color: #111827 !important;
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ export interface ExploreTagEntry {
|
|||||||
count: number;
|
count: number;
|
||||||
representative_image_id: number;
|
representative_image_id: number;
|
||||||
thumbnail_path: string | null;
|
thumbnail_path: string | null;
|
||||||
|
has_ai_source: boolean;
|
||||||
|
has_user_source: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RelatedTagEntry {
|
export interface RelatedTagEntry {
|
||||||
@@ -585,6 +587,8 @@ interface GalleryState {
|
|||||||
queueTaggingForImage: (imageId: number) => Promise<number>;
|
queueTaggingForImage: (imageId: number) => Promise<number>;
|
||||||
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
|
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
|
||||||
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
|
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
|
||||||
|
resetAiTags: (folderId?: number | null) => Promise<number>;
|
||||||
|
resetAiTagsForFolders: (folderIds: number[]) => Promise<number>;
|
||||||
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
|
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
|
||||||
scanDuplicates: (folderId?: number | null) => Promise<void>;
|
scanDuplicates: (folderId?: number | null) => Promise<void>;
|
||||||
toggleDuplicateSelected: (imageId: number) => void;
|
toggleDuplicateSelected: (imageId: number) => void;
|
||||||
@@ -2383,6 +2387,26 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
|||||||
return cleared;
|
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) => {
|
getImageTags: async (imageId) => {
|
||||||
return invoke<ImageTag[]>("get_image_tags", {
|
return invoke<ImageTag[]>("get_image_tags", {
|
||||||
params: { image_id: imageId },
|
params: { image_id: imageId },
|
||||||
|
|||||||
Reference in New Issue
Block a user