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
+151 -15
View File
@@ -678,6 +678,51 @@ const TAG_MANAGE_SORTS: { value: TagManageSort; label: string }[] = [
{ value: "za", label: "Z-A" },
];
function AiSourceGlyph({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M8 2.75l.82 2.42a2.1 2.1 0 0 0 1.32 1.32l2.41.81-2.41.82a2.1 2.1 0 0 0-1.32 1.32L8 11.85l-.82-2.41a2.1 2.1 0 0 0-1.32-1.32L3.45 7.3l2.41-.81a2.1 2.1 0 0 0 1.32-1.32L8 2.75Z"
stroke="currentColor"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path d="M12.25 11.15l.25.74.75.26-.75.25-.25.75-.26-.75-.74-.25.74-.26.26-.74Z" fill="currentColor" />
</svg>
);
}
function RenameGlyph({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3.25 11.85l.45-2.14 5.95-5.95a1.33 1.33 0 0 1 1.88 0l.71.71a1.33 1.33 0 0 1 0 1.88L6.29 12.3l-2.14.45a.76.76 0 0 1-.9-.9Z"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
<path d="M8.75 4.65l2.6 2.6" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
</svg>
);
}
function DeleteGlyph({ className = "" }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3.25 4.65h9.5" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
<path d="M6.35 4.55V3.7c0-.55.45-1 1-1h1.3c.55 0 1 .45 1 1v.85" stroke="currentColor" strokeLinecap="round" strokeWidth="1.35" />
<path
d="M5.1 6.2l.35 5.65c.04.63.56 1.12 1.19 1.12h2.72c.63 0 1.15-.49 1.19-1.12l.35-5.65"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.35"
/>
</svg>
);
}
// Compact management tile for a single tag. Rename doubles as merge when the new
// name already exists, and delete applies across the scoped tag set.
function TagManageTile({
@@ -719,12 +764,20 @@ function TagManageTile({
}
};
const sourceLabel = entry.has_ai_source
? entry.has_user_source
? "Used by AI tags and user tags"
: "AI-generated tag"
: "User tag";
return (
<div
data-tag-manager-tile
className={`tag-manager-tile group relative min-w-0 rounded-lg border border-white/[0.06] bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:border-white/[0.14] focus-within:bg-white/[0.045] hover:border-white/[0.12] hover:bg-white/[0.04] ${
editing || confirming ? "min-h-[82px]" : "min-h-[46px]"
}`}
className={`tag-manager-tile ${entry.has_ai_source ? "tag-manager-tile-ai" : ""} group relative min-w-0 rounded-lg border bg-white/[0.018] px-3 py-2 transition-[background-color,border-color,transform] duration-150 focus-within:bg-white/[0.045] hover:bg-white/[0.04] ${
entry.has_ai_source
? "border-white/[0.08] shadow-[inset_0_1px_0_rgba(125,211,252,0.055)] focus-within:border-white/[0.15] hover:border-white/[0.13]"
: "border-white/[0.06] focus-within:border-white/[0.14] hover:border-white/[0.12]"
} ${editing || confirming ? "min-h-[82px]" : "min-h-[46px]"}`}
>
<div className="flex min-w-0 items-start gap-2">
{editing ? (
@@ -749,9 +802,20 @@ function TagManageTile({
</button>
</Tooltip>
)}
<span className="tag-manager-count absolute right-2.5 top-2 shrink-0 rounded-full bg-white/[0.055] px-2 py-0.5 text-[10px] tabular-nums text-white/38">
{entry.count.toLocaleString()}
</span>
<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 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>
</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>
@@ -815,17 +883,24 @@ function TagManageList({
onSearch,
onRename,
onDelete,
onResetAiTags,
scopeLabel,
}: {
entries: ExploreTagEntry[];
onSearch: (tag: string) => void;
onRename: (from: string, to: string) => Promise<void>;
onDelete: (tag: string) => Promise<void>;
onResetAiTags: () => Promise<number>;
scopeLabel: string;
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const measureRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<TagManageSort>("count_desc");
const [columns, setColumns] = useState(3);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetStatus, setResetStatus] = useState<string | null>(null);
useLayoutEffect(() => {
const el = measureRef.current;
@@ -870,6 +945,30 @@ function TagManageList({
const visibleItems = rowVirtualizer.getVirtualItems();
const totalUses = useMemo(() => entries.reduce((sum, entry) => sum + entry.count, 0), [entries]);
const runResetAiTags = async () => {
if (!resetConfirming) {
setResetConfirming(true);
setResetStatus(`Reset AI tags for ${scopeLabel}? User tags are preserved, and retagging is not queued automatically.`);
return;
}
setResetting(true);
setResetStatus(null);
try {
const count = await onResetAiTags();
setResetStatus(
count === 0
? "No AI tag data found for this scope."
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
);
} catch (error) {
setResetStatus(String(error));
} finally {
setResetting(false);
setResetConfirming(false);
}
};
return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="tag-manager-header shrink-0 border-b border-white/[0.05] bg-black/[0.08] px-6 py-4">
@@ -887,9 +986,33 @@ function TagManageList({
<p className="tag-manager-help mt-2 max-w-2xl text-[11px] leading-relaxed text-white/28">
Rename tags to clean them up, rename into an existing tag to merge, or delete a tag everywhere in the current scope.
</p>
{resetStatus ? <p className="mt-2 text-[11px] leading-relaxed text-amber-200/80">{resetStatus}</p> : null}
</div>
<div className="flex min-w-[320px] flex-1 flex-wrap justify-end gap-2">
<button
className={`tag-manager-reset-button h-9 rounded-lg border px-3 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${
resetConfirming
? "tag-manager-reset-button-confirm border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25"
: "border-white/8 bg-black/20 text-white/50 hover:bg-white/[0.06] hover:text-white/80"
}`}
onClick={() => void runResetAiTags()}
disabled={resetting}
>
{resetting ? "Resetting..." : resetConfirming ? "Confirm reset" : "Reset AI tags"}
</button>
{resetConfirming ? (
<button
className="tag-manager-reset-cancel h-9 rounded-lg border border-white/8 bg-black/20 px-3 text-xs text-white/45 transition-colors hover:bg-white/[0.06] hover:text-white/75 disabled:opacity-50"
onClick={() => {
setResetConfirming(false);
setResetStatus(null);
}}
disabled={resetting}
>
Cancel
</button>
) : null}
<div className="relative min-w-[220px] flex-1 sm:max-w-sm">
<input
className="tag-manager-filter h-9 w-full rounded-lg border border-white/8 bg-black/20 px-3 pr-8 text-sm text-white/85 outline-none transition-colors placeholder:text-white/22 focus:border-blue-400/35 focus:bg-black/28"
@@ -983,11 +1106,22 @@ export function ExploreView() {
const searchForTag = useGalleryStore((state) => state.searchForTag);
const renameTag = useGalleryStore((state) => state.renameTag);
const deleteTag = useGalleryStore((state) => state.deleteTag);
const resetAiTags = useGalleryStore((state) => state.resetAiTags);
const folders = useGalleryStore((state) => state.folders);
const selectedFolderId = useGalleryStore((state) => state.selectedFolderId);
// Manage mode lives in the store so it can be opened from elsewhere (Settings).
const manageTags = useGalleryStore((state) => state.tagManagerOpen);
const setManageTags = useGalleryStore((state) => state.setTagManagerOpen);
const handleDeleteTag = async (tag: string) => { await deleteTag(tag); };
const tagManagerScopeLabel =
selectedFolderId === null
? "all media"
: folders.find((folder) => folder.id === selectedFolderId)?.name ?? "the current folder";
const handleResetAiTags = async () => {
const count = await resetAiTags(selectedFolderId);
await loadExploreTags({ force: true });
return count;
};
useEffect(() => {
if (exploreMode === "visual") void loadVisualClusters();
@@ -1080,6 +1214,8 @@ export function ExploreView() {
onSearch={searchForTag}
onRename={renameTag}
onDelete={handleDeleteTag}
onResetAiTags={handleResetAiTags}
scopeLabel={tagManagerScopeLabel}
/>
</div>
) : (
+69 -2
View File
@@ -166,6 +166,8 @@ export function SettingsModal() {
const [taggerQueueStatus, setTaggerQueueStatus] = useState<string | null>(null);
const [taggerQueueing, setTaggerQueueing] = useState(false);
const [taggerClearing, setTaggerClearing] = useState(false);
const [taggerResetConfirming, setTaggerResetConfirming] = useState(false);
const [taggerResetting, setTaggerResetting] = useState(false);
const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false);
const [taggerAccelerationError, setTaggerAccelerationError] = useState<string | null>(null);
const [taggerModelSwitching, setTaggerModelSwitching] = useState(false);
@@ -226,6 +228,8 @@ export function SettingsModal() {
const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders);
const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs);
const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders);
const resetAiTags = useGalleryStore((state) => state.resetAiTags);
const resetAiTagsForFolders = useGalleryStore((state) => state.resetAiTagsForFolders);
const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder);
const notificationsPaused = useGalleryStore((state) => state.notificationsPaused);
const setNotificationsPaused = useGalleryStore((state) => state.setNotificationsPaused);
@@ -341,6 +345,7 @@ export function SettingsModal() {
setTaggerClearing(true);
}
setTaggerQueueStatus(null);
setTaggerResetConfirming(false);
void perform
.then((count) => {
@@ -368,6 +373,45 @@ export function SettingsModal() {
});
};
const runResetAiTags = () => {
if (!taggerResetConfirming) {
setTaggerResetConfirming(true);
setTaggerQueueStatus(
`Reset AI tags for ${queueScopeLabel}? User tags are preserved, and retagging is not queued automatically.`,
);
return;
}
const selectedIds = taggingQueueFolderIds;
const perform =
taggingQueueScope === "all"
? resetAiTags(null)
: selectedIds.length > 0
? resetAiTagsForFolders(selectedIds)
: Promise.resolve(0);
setTaggerResetting(true);
setTaggerQueueStatus(null);
void perform
.then((count) => {
if (taggingQueueScope === "selected" && selectedIds.length === 0) {
setTaggerQueueStatus("Choose at least one folder before resetting AI tags.");
return;
}
setTaggerQueueStatus(
count === 0
? "No AI tag data found for the current target."
: `Reset AI tags for ${count.toLocaleString()} image${count === 1 ? "" : "s"}. Queue tagging when you're ready to retag.`,
);
})
.catch((error) => setTaggerQueueStatus(String(error)))
.finally(() => {
setTaggerResetting(false);
setTaggerResetConfirming(false);
});
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/65 px-6 backdrop-blur-sm" onClick={() => setSettingsOpen(false)}>
<div
@@ -673,17 +717,40 @@ export function SettingsModal() {
<button
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
onClick={() => runQueueAction("queue")}
disabled={!taggerReady || taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
disabled={!taggerReady || taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerQueueing ? "Queueing..." : "Queue tagging"}
</button>
<button
className="rounded-md border border-amber-400/20 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 transition-colors hover:bg-amber-500/15 disabled:cursor-not-allowed disabled:opacity-45 light-theme:border-amber-500/50 light-theme:bg-amber-50 light-theme:text-amber-700 light-theme:hover:bg-amber-100"
onClick={() => runQueueAction("clear")}
disabled={taggerQueueing || taggerClearing || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerClearing ? "Clearing..." : "Clear queued jobs"}
</button>
<button
className={`rounded-md border px-3 py-1.5 text-xs transition-colors disabled:cursor-not-allowed disabled:opacity-45 ${
taggerResetConfirming
? "border-red-400/30 bg-red-500/15 text-red-200 hover:bg-red-500/25 light-theme:border-red-500/50 light-theme:bg-red-50 light-theme:text-red-700 light-theme:hover:bg-red-100"
: "border-white/10 bg-white/[0.055] text-gray-300 hover:bg-white/10 hover:text-white light-theme:border-gray-700/50 light-theme:bg-gray-900 light-theme:text-white light-theme:hover:bg-gray-800 light-theme:hover:text-white"
}`}
onClick={runResetAiTags}
disabled={taggerQueueing || taggerClearing || taggerResetting || (taggingQueueScope === "selected" && taggingQueueFolderIds.length === 0)}
>
{taggerResetting ? "Resetting..." : taggerResetConfirming ? "Confirm reset" : "Reset AI tags"}
</button>
{taggerResetConfirming ? (
<button
className="rounded-md border border-white/10 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:bg-white/[0.06] hover:text-white light-theme:border-gray-700/50 light-theme:text-gray-600 light-theme:hover:bg-gray-900 light-theme:hover:text-white"
onClick={() => {
setTaggerResetConfirming(false);
setTaggerQueueStatus(null);
}}
disabled={taggerResetting}
>
Cancel
</button>
) : null}
</div>
</SettingsItem>
+58
View File
@@ -106,6 +106,38 @@ function searchTagsAutocomplete(payload: unknown): ExploreTagEntry[] {
.slice(0, limit);
}
function rebuildExploreTags(): ExploreTagEntry[] {
const entries = new Map<
string,
{ imageIds: Set<number>; representativeImageId: number; hasAiSource: boolean; hasUserSource: boolean }
>();
for (const [imageIdString, imageTags] of Object.entries(db.tagsByImageId)) {
const imageId = Number(imageIdString);
for (const imageTag of imageTags) {
const entry =
entries.get(imageTag.tag) ??
{ imageIds: new Set<number>(), representativeImageId: imageId, hasAiSource: false, hasUserSource: false };
entry.imageIds.add(imageId);
if (imageTag.source === "ai") entry.hasAiSource = true;
if (imageTag.source === "user") entry.hasUserSource = true;
entries.set(imageTag.tag, entry);
}
}
return [...entries.entries()]
.map(([tag, entry]) => {
const representative = db.images.find((image) => image.id === entry.representativeImageId);
return {
tag,
count: entry.imageIds.size,
representative_image_id: entry.representativeImageId,
thumbnail_path: representative?.thumbnail_path ?? null,
has_ai_source: entry.hasAiSource,
has_user_source: entry.hasUserSource,
};
})
.sort((left, right) => right.count - left.count || left.tag.localeCompare(right.tag));
}
function semanticSearch(payload: unknown): ImageRecord[] {
const p = params(payload);
const query = String(p.query ?? "").toLowerCase();
@@ -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":
+3
View File
@@ -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));
}
+41
View File
@@ -259,6 +259,33 @@ html[data-theme="subtle-light"] .tag-manager-filter:focus {
border-color: #7aa2e3 !important;
}
html[data-theme="subtle-light"] .tag-manager-reset-button,
html[data-theme="subtle-light"] .tag-manager-reset-cancel {
background: #ebe8df !important;
border-color: #c9c1b4 !important;
color: #5f5a52 !important;
box-shadow: inset 0 1px 0 rgb(255 255 255 / 0.58) !important;
}
html[data-theme="subtle-light"] .tag-manager-reset-button:hover,
html[data-theme="subtle-light"] .tag-manager-reset-cancel:hover {
background: #e2ded3 !important;
border-color: #b9b0a2 !important;
color: #24201b !important;
}
html[data-theme="subtle-light"] .tag-manager-reset-button-confirm {
background: rgb(254 242 242 / 0.95) !important;
border-color: rgb(220 38 38 / 0.34) !important;
color: #991b1b !important;
}
html[data-theme="subtle-light"] .tag-manager-reset-button-confirm:hover {
background: #fee2e2 !important;
border-color: rgb(220 38 38 / 0.48) !important;
color: #7f1d1d !important;
}
html[data-theme="subtle-light"] .tag-manager-clear {
color: #6b645a !important;
}
@@ -292,6 +319,20 @@ html[data-theme="subtle-light"] .tag-manager-count {
color: #6b645a !important;
}
html[data-theme="subtle-light"] .tag-manager-tile-ai {
box-shadow: inset 0 1px 0 rgb(14 116 144 / 0.08) !important;
}
html[data-theme="subtle-light"] .tag-manager-count-ai {
background: rgb(14 116 144 / 0.1) !important;
border-color: rgb(14 116 144 / 0.18) !important;
color: #0d5f6f !important;
}
html[data-theme="subtle-light"] .tag-manager-ai-glyph {
color: #0d5f6f !important;
}
html[data-theme="subtle-light"] .tag-manager-edit-input {
background: #fffdfa !important;
color: #111827 !important;
+24
View File
@@ -215,6 +215,8 @@ export interface ExploreTagEntry {
count: number;
representative_image_id: number;
thumbnail_path: string | null;
has_ai_source: boolean;
has_user_source: boolean;
}
export interface RelatedTagEntry {
@@ -585,6 +587,8 @@ interface GalleryState {
queueTaggingForImage: (imageId: number) => Promise<number>;
clearTaggingJobs: (folderId?: number | null) => Promise<number>;
clearTaggingJobsForFolders: (folderIds: number[]) => Promise<number>;
resetAiTags: (folderId?: number | null) => Promise<number>;
resetAiTagsForFolders: (folderIds: number[]) => Promise<number>;
loadDuplicateScanCache: (folderId?: number | null) => Promise<void>;
scanDuplicates: (folderId?: number | null) => Promise<void>;
toggleDuplicateSelected: (imageId: number) => void;
@@ -2383,6 +2387,26 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
return cleared;
},
resetAiTags: async (folderId = get().selectedFolderId) => {
const reset = await invoke<number>("reset_ai_tags", {
params: { folder_id: folderId ?? null, folder_ids: null },
});
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
await get().loadBackgroundJobProgress();
await get().loadImages(true);
return reset;
},
resetAiTagsForFolders: async (folderIds) => {
const reset = await invoke<number>("reset_ai_tags", {
params: { folder_id: null, folder_ids: folderIds },
});
set({ exploreTagsFolderId: undefined, visualClusterFolderId: undefined, visualClusterEntries: [] });
await get().loadBackgroundJobProgress();
await get().loadImages(true);
return reset;
},
getImageTags: async (imageId) => {
return invoke<ImageTag[]>("get_image_tags", {
params: { image_id: imageId },