Cluster B frontend, milestone #99. #7c: AllowlistTable gains Applied + Covers columns and a live 'covers ~N at T' projection as the operator drags a row's threshold (debounced coverage call, then commits the threshold). allowlist store gains coverage(tagId, threshold) and refreshes coverage_count after a save. #7d: suggestions store surfaces a non-blocking toast when accept/alias newly allowlists a tag — '<verb>: <tag> — allowlisted, auto-applying to ~N images' (N is the projection; apply runs async). Falls back to the plain toast when the tag was already allowlisted. #8b: TagsView merge picker now previews the merge via usePreviewCommit before committing — shows images moving / already-on-target / series pages / alias-or- delete / a thumbnail sample, blocks the Merge button on an incompatible kind/fandom. adminStore.mergeTags gains a dryRun option. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XCUHUGQLrBrkgyk1t49kpX
189 lines
7.5 KiB
JavaScript
189 lines
7.5 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { toast } from '../utils/toast.js'
|
|
import { ref } from 'vue'
|
|
import { useApi } from '../composables/useApi.js'
|
|
import { useAsyncAction } from '../composables/useAsyncAction.js'
|
|
import { useInflightToken } from '../composables/useInflightToken.js'
|
|
|
|
// Category display order: people first, general last.
|
|
// 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired — only
|
|
// character and general surface as suggestion categories now.
|
|
export const CATEGORY_ORDER = ['character', 'general']
|
|
export const CATEGORY_LABELS = {
|
|
character: 'Character',
|
|
general: 'General'
|
|
}
|
|
|
|
export const useSuggestionsStore = defineStore('suggestions', () => {
|
|
const api = useApi()
|
|
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
|
|
// The typed tag-input dropdown searches the model's FULL prediction set for
|
|
// the image (min=0, down to the store floor) so low-confidence actions/
|
|
// features can be picked in canonical formatting (operator-asked 2026-06-09).
|
|
const allByCategory = ref({})
|
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
|
let currentImageId = null
|
|
const inflightAll = useInflightToken()
|
|
// Audit 2026-06-02: this store had no inflight guard — a late
|
|
// /suggestions response from a prior image could overwrite
|
|
// byCategory while currentImageId pointed at a new one, and
|
|
// accept() dereferenced currentImageId AFTER an awaited POST so
|
|
// the subsequent /suggestions/accept could apply A's chosen tag
|
|
// to image B (and push it to the allowlist). Both fixed below
|
|
// by capturing imageId at call-time and gating writes on the token.
|
|
const inflight = useInflightToken()
|
|
|
|
async function load(imageId) {
|
|
// Cancel any in-flight load from the previous image so its late
|
|
// response can't overwrite this image's byCategory.
|
|
inflight.cancel()
|
|
currentImageId = imageId
|
|
byCategory.value = {} // cleared upfront so it stays empty on error
|
|
const t = inflight.claim()
|
|
await run(async () => {
|
|
const body = await api.get(`/api/images/${imageId}/suggestions`)
|
|
if (!t.isCurrent()) return
|
|
byCategory.value = body.by_category || {}
|
|
})
|
|
}
|
|
|
|
// Load the full prediction set for the dropdown (separate inflight guard so a
|
|
// late response from a prior image can't overwrite the current one's list).
|
|
async function loadAll(imageId) {
|
|
inflightAll.cancel()
|
|
allByCategory.value = {}
|
|
const t = inflightAll.claim()
|
|
try {
|
|
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
|
params: { min: 0 },
|
|
})
|
|
if (!t.isCurrent()) return
|
|
allByCategory.value = body.by_category || {}
|
|
} catch {
|
|
// Dropdown is best-effort — the panel surfaces load errors.
|
|
}
|
|
}
|
|
|
|
// A stable identity for a suggestion across the two lists (panel vs dropdown
|
|
// are separate fetches, so object identity differs): tag id when known, else
|
|
// the raw display key.
|
|
function _keyOf(s) {
|
|
return s.canonical_tag_id != null
|
|
? `id:${s.canonical_tag_id}`
|
|
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
|
}
|
|
|
|
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
|
// dropdown's full list so it can't reappear in either surface.
|
|
function _dropEverywhere(suggestion) {
|
|
const key = _keyOf(suggestion)
|
|
const cat = suggestion.category
|
|
for (const map of [byCategory.value, allByCategory.value]) {
|
|
const list = map[cat]
|
|
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
|
|
}
|
|
}
|
|
|
|
async function accept(suggestion) {
|
|
// Capture imageId so a mid-flight prev/next can't reroute the
|
|
// accept POST to a different image AND push the tag to that
|
|
// image's allowlist.
|
|
const imageId = currentImageId
|
|
if (imageId == null) return
|
|
// Raw tags (creates_new_tag) have no canonical_tag_id; the backend's
|
|
// accept endpoint needs a tag_id, so for raw tags we create the tag
|
|
// first via the existing /api/tags endpoint, then accept by id.
|
|
let tagId = suggestion.canonical_tag_id
|
|
if (tagId == null) {
|
|
const created = await api.post('/api/tags', {
|
|
body: { name: suggestion.display_name, kind: suggestion.category }
|
|
})
|
|
tagId = created.id
|
|
}
|
|
const res = await api.post(`/api/images/${imageId}/suggestions/accept`, {
|
|
body: { tag_id: tagId }
|
|
})
|
|
// Only drop from THIS image's category list — if the user navigated,
|
|
// the new image has its own suggestions and this drop would corrupt them.
|
|
if (currentImageId === imageId) {
|
|
_dropEverywhere(suggestion)
|
|
}
|
|
_acceptToast('Tagged', suggestion.display_name, res)
|
|
}
|
|
|
|
// One non-blocking toast for accept/alias. When the accept newly allowlisted
|
|
// the tag, surface the coverage PROJECTION (#7) so the operator sees the
|
|
// auto-apply reach without a blocking pre-accept preview — the apply itself
|
|
// runs async, hence "~N".
|
|
function _acceptToast(verb, displayName, res) {
|
|
if (res?.allowlisted) {
|
|
const n = res.projected_count
|
|
toast({
|
|
text: `${verb}: ${displayName} — allowlisted, auto-applying to ~${n} image${n === 1 ? '' : 's'}`,
|
|
type: 'success'
|
|
})
|
|
} else {
|
|
toast({ text: `${verb}: ${displayName}`, type: 'success' })
|
|
}
|
|
}
|
|
|
|
async function aliasAccept(suggestion, canonicalTagId) {
|
|
const imageId = currentImageId
|
|
if (imageId == null) return
|
|
// The alias MUST be stored under the raw model key — resolution looks up the
|
|
// raw prediction key, not the normalized display name. Sending display_name
|
|
// (the old bug) stored an alias that never resolved, so the prediction kept
|
|
// reappearing unaliased. raw_name is null only for centroid hits, which
|
|
// can't be aliased (the UI hides the action for them).
|
|
const aliasString = suggestion.raw_name ?? suggestion.display_name
|
|
const res = await api.post(`/api/images/${imageId}/suggestions/alias`, {
|
|
body: {
|
|
alias_string: aliasString,
|
|
alias_category: suggestion.category,
|
|
canonical_tag_id: canonicalTagId
|
|
}
|
|
})
|
|
if (currentImageId === imageId) {
|
|
_dropEverywhere(suggestion)
|
|
}
|
|
_acceptToast('Aliased & tagged', suggestion.display_name, res)
|
|
}
|
|
|
|
// Remove the alias behind an aliased suggestion (the raw prediction reverts to
|
|
// its unaliased form on reload). The canonical tag stays applied if it was
|
|
// accepted — this only undoes the model-key→tag mapping.
|
|
async function removeAlias(suggestion) {
|
|
const imageId = currentImageId
|
|
if (imageId == null || suggestion.raw_name == null) return
|
|
await api.delete(
|
|
`/api/aliases/${encodeURIComponent(suggestion.raw_name)}/${encodeURIComponent(suggestion.category)}`
|
|
)
|
|
if (currentImageId === imageId) {
|
|
await load(imageId)
|
|
await loadAll(imageId)
|
|
}
|
|
toast({ text: `Alias removed: ${suggestion.display_name}`, type: 'success' })
|
|
}
|
|
|
|
async function dismiss(suggestion) {
|
|
const imageId = currentImageId
|
|
if (imageId == null) return
|
|
// Dismiss needs a tag_id; raw tags have none, so dismissing a raw
|
|
// suggestion just hides it client-side (nothing to persist a rejection
|
|
// against until the tag exists).
|
|
if (suggestion.canonical_tag_id != null) {
|
|
await api.post(`/api/images/${imageId}/suggestions/dismiss`, {
|
|
body: { tag_id: suggestion.canonical_tag_id }
|
|
})
|
|
}
|
|
if (currentImageId === imageId) {
|
|
_dropEverywhere(suggestion)
|
|
}
|
|
}
|
|
|
|
return {
|
|
byCategory, allByCategory, loading, error,
|
|
load, loadAll, accept, aliasAccept, removeAlias, dismiss
|
|
}
|
|
})
|