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
+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));
}